diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2d637f7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# Git +.git +.gitignore + +# Documentation +docs/*.md +*.md + +# Build artifacts +bin/ +*.exe +*.dll +*.so +*.dylib + +# Test artifacts +coverage.out +coverage.html +*.test + +# Local environment +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker +Dockerfile* +docker-compose*.yml +.dockerignore + +# CI/CD +.github/ +k8s/ + +# Temporary +tmp/ +temp/ +*.tmp diff --git a/.env.example b/.env.example deleted file mode 100644 index 25f4475..0000000 --- a/.env.example +++ /dev/null @@ -1,72 +0,0 @@ -# ============================================================================= -# Application -# ============================================================================= -APP_ENV=development - -# ============================================================================= -# Server -# ============================================================================= -SERVER_PORT=8080 -SERVER_READ_TIMEOUT=30 -SERVER_WRITE_TIMEOUT=30 -SERVER_IDLE_TIMEOUT=120 -MAX_REQUEST_BODY_SIZE=10485760 - -# ============================================================================= -# Database (PostgreSQL) -# ============================================================================= -DB_HOST=localhost -DB_PORT=5432 -DB_USER=postgres -DB_PASSWORD=postgres -DB_NAME=go_codebase -DB_SSLMODE=disable -DB_MAX_OPEN_CONNS=25 -DB_MAX_IDLE_CONNS=5 -DB_CONN_MAX_LIFETIME=300 - -# ============================================================================= -# Redis -# ============================================================================= -REDIS_ADDR=localhost:6379 -REDIS_PASSWORD= -REDIS_DB=0 -REDIS_POOL_SIZE=10 - -# ============================================================================= -# NATS -# ============================================================================= -NATS_URL=nats://localhost:4222 - -# ============================================================================= -# Authentication & Security -# ============================================================================= -JWT_SECRET=your-secret-key-change-in-production -JWT_EXPIRATION=3600 -MAX_LOGIN_ATTEMPTS=5 -LOCKOUT_DURATION=900 -TOKEN_DENYLIST=true -CORS_ORIGINS=* -CORS_ALLOW_CREDENTIALS=true -CORS_MAX_AGE=300 - -# ============================================================================= -# Rate Limiting -# ============================================================================= -RATE_LIMIT_REQUESTS=100 -RATE_LIMIT_WINDOW=60 - -# ============================================================================= -# Idempotency -# ============================================================================= -IDEMPOTENCY_ENABLED=false -IDEMPOTENCY_TTL=86400 - -# ============================================================================= -# Observability (Logging & Tracing) -# ============================================================================= -LOG_LEVEL=info -LOG_FORMAT=json -OTEL_SERVICE_NAME=go-codebase -OTEL_EXPORTER_ENDPOINT=http://localhost:4318 -OTEL_SAMPLE_RATE=1.0 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..0c3e6b1 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,92 @@ +name: CD + +on: + push: + branches: + - main + tags: + - 'v*.*.*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + name: Build and Push Docker Image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy-staging: + name: Deploy to Staging + runs-on: ubuntu-latest + needs: build-and-push + if: github.ref == 'refs/heads/main' + environment: + name: staging + url: https://staging.go-codebase.example.com + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Deploy to staging + run: | + echo "Deploying to staging environment..." + echo "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" + # Add your staging deployment commands here + # Example: ssh, kubectl, helm, or webhook + + deploy-production: + name: Deploy to Production + runs-on: ubuntu-latest + needs: build-and-push + if: startsWith(github.ref, 'refs/tags/v') + environment: + name: production + url: https://go-codebase.example.com + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Deploy to production + run: | + echo "Deploying to production environment..." + echo "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}" + # Add your production deployment commands here + # Example: ssh, kubectl, helm, or webhook diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..159938c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,153 @@ +name: CI + +on: + push: + branches: + - main + - development + pull_request: + branches: + - main + - development + +env: + GO_VERSION: "1.25" + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Generate code + run: | + go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest + go install github.com/swaggo/swag/cmd/swag@latest + make sqlc + make swagger + + - name: Install golangci-lint + run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + + - name: Run golangci-lint + run: golangci-lint run --timeout=5m + + test: + name: Test + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: go_codebase_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Generate code + run: | + go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest + go install github.com/swaggo/swag/cmd/swag@latest + make sqlc + make swagger + + - name: Run tests + env: + DB_HOST: localhost + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: postgres + DB_NAME: go_codebase_test + DB_SSLMODE: disable + REDIS_ADDR: localhost:6379 + JWT_SECRET: test-secret-key-not-for-production + run: go test -race -count=1 ./... + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Generate code + run: | + go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest + go install github.com/swaggo/swag/cmd/swag@latest + make sqlc + make swagger + + - name: Build binary + run: go build -o bin/server ./cmd/api + + - name: Build Docker image + run: docker build -t go-codebase:${{ github.sha }} . diff --git a/.gitignore b/.gitignore index c1e34bc..8b68e51 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ vendor/ docs/docs.go docs/swagger.json docs/swagger.yaml +.worktrees + +# SQLc generated code (regenerate with `make sqlc`) +**/infrastructure/persistence/sqlc/ +internal/shared/auditlog/sqlc/ diff --git a/.golangci.yml b/.golangci.yml index 6deb850..f4600e2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,6 @@ run: timeout: 5m + go: "1.24" linters: enable: @@ -17,7 +18,8 @@ linters: linters-settings: govet: - check-shadowing: true + enable: + - shadow revive: rules: - name: exported diff --git a/Dockerfile b/Dockerfile index 8b6c19f..101613d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,45 @@ -FROM golang:1.22-alpine AS builder +# Build stage +FROM golang:1.25-alpine AS builder -RUN apk add --no-cache git +RUN apk add --no-cache git ca-certificates tzdata WORKDIR /app +# Copy dependency files first for better layer caching COPY go.mod go.sum ./ RUN go mod download +# Copy source code COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/api +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/server ./cmd/api +# Final stage FROM alpine:3.19 -RUN apk --no-cache add ca-certificates tzdata +RUN apk --no-cache add ca-certificates tzdata curl + +# Create non-root user +RUN addgroup -g 1000 appgroup && \ + adduser -u 1000 -G appgroup -s /bin/sh -D appuser WORKDIR /app +# Copy binary and required files COPY --from=builder /app/server . COPY --from=builder /app/configs ./configs COPY --from=builder /app/migrations ./migrations +# Change ownership to non-root user +RUN chown -R appuser:appgroup /app + +USER appuser + EXPOSE 8080 +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + CMD ["./server"] diff --git a/Makefile b/Makefile index 7c73a74..801ad81 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,33 @@ -.PHONY: run build test lint fmt migrate-up migrate-down sqlc swagger docker-up docker-down clean rename +.PHONY: run build test lint fmt migrate-up migrate-down sqlc swagger docker-up docker-down docker-dev docker-dev-down clean rename install-tools precommit install-hooks APP_NAME := go-codebase BUILD_DIR := bin +GOINSTALL = go install + +GOLANGCI_LINT := $(shell command -v golangci-lint 2>/dev/null) +GOIMPORTS := $(shell command -v goimports 2>/dev/null) +GOOSE := $(shell command -v goose 2>/dev/null) +SQLC := $(shell command -v sqlc 2>/dev/null) +SWAG := $(shell command -v swag 2>/dev/null) + +install-tools: +ifndef GOLANGCI_LINT + $(GOINSTALL) github.com/golangci/golangci-lint/cmd/golangci-lint@latest +endif +ifndef GOIMPORTS + $(GOINSTALL) golang.org/x/tools/cmd/goimports@latest +endif +ifndef GOOSE + $(GOINSTALL) github.com/pressly/goose/v3/cmd/goose@latest +endif +ifndef SQLC + $(GOINSTALL) github.com/sqlc-dev/sqlc/cmd/sqlc@latest +endif +ifndef SWAG + $(GOINSTALL) github.com/swaggo/swag/cmd/swag@latest +endif + run: go run ./cmd/api @@ -16,25 +41,45 @@ test-coverage: go test -v -count=1 -coverprofile=coverage.out ./... go tool cover -html=coverage.out -o coverage.html -lint: +lint: install-tools golangci-lint run ./... -fmt: +fmt: install-tools go fmt ./... goimports -w . -migrate-up: +migrate-up: install-tools goose -dir migrations up -migrate-down: +migrate-down: install-tools goose -dir migrations down -sqlc: +sqlc: install-tools sqlc generate -swagger: +swagger: install-tools swag init -g cmd/api/swagger.go -o docs --parseDependency --parseInternal +precommit: install-tools mod-tidy + @echo "=== Checking formatting ===" + @if [ -n "$$(go fmt ./...)" ]; then \ + echo "ERROR: Files not formatted. Run 'make fmt' and commit again."; \ + exit 1; \ + fi + @echo "=== Linting ===" + golangci-lint run ./... + @echo "=== Building ===" + go build ./... + @echo "=== Testing ===" + go test -count=1 ./... + @echo "=== All checks passed ===" + +install-hooks: + @echo "Installing pre-commit hook..." + @printf '#!/bin/sh\nmake -C "$(CURDIR)" precommit\n' > .git/hooks/pre-commit + @chmod +x .git/hooks/pre-commit + @echo "Pre-commit hook installed." + docker-up: docker compose up -d @@ -44,6 +89,21 @@ docker-down: docker-build: docker compose build +docker-prod-up: + docker compose -f docker-compose.prod.yml up -d + +docker-prod-down: + docker compose -f docker-compose.prod.yml down + +docker-prod-migrate: + docker compose -f docker-compose.prod.yml --profile migrate run --rm migrate + +docker-dev: + docker compose -f docker-compose.dev.yml up -d + +docker-dev-down: + docker compose -f docker-compose.dev.yml down + clean: rm -rf $(BUILD_DIR) coverage.out coverage.html diff --git a/README.md b/README.md index f36afac..4bc4a72 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,25 @@ The server starts at `http://localhost:8080`. - **Swagger UI**: `http://localhost:8080/swagger/index.html` - **Health check**: `http://localhost:8080/health` +## Development + +```bash +# Install missing tools (golangci-lint, goimports, goose, sqlc, swag) +make install-tools + +# Run all checks before committing +make precommit + +# Install git pre-commit hook (runs make precommit automatically) +make install-hooks + +# Lint +make lint + +# Format code +make fmt +``` + ## Tech Stack | Component | Technology | @@ -43,7 +62,8 @@ The server starts at `http://localhost:8080`. | RBAC | Casbin | | Cache | Redis | | Messaging | NATS | -| Tracing | OpenTelemetry | +| Events | In-memory EventBus (interface; RabbitMQ/Kafka-ready) | +| Tracing | OpenTelemetry (OTLP HTTP, W3C propagation) | | Docs | Swagger (swaggo/swag) | | Testing | Testify + GoMock | | Container | Docker + Docker Compose | @@ -56,6 +76,9 @@ The server starts at `http://localhost:8080`. - **Modular Monolith** - Each module is independent - **Vertical Slice** - Each feature is self-contained - **Loosely Coupled** - Infrastructure implementations behind interfaces, swappable via DI +- **Event-Driven** - Domain events decouple side effects (e.g., email sending) from core business logic +- **Unified API** - Consistent response envelope and centralized error handling across all endpoints +- **OpenTelemetry** - Distributed tracing with trace/span IDs attached to every log entry ## API @@ -67,6 +90,10 @@ The server starts at `http://localhost:8080`. | POST | /api/v1/auth/login | Login and get tokens | | POST | /api/v1/auth/refresh | Refresh access token | | POST | /api/v1/auth/logout | Revoke refresh token | +| GET | /api/v1/auth/verify-email?token= | Verify email address | +| POST | /api/v1/auth/forgot-password | Request password reset | +| POST | /api/v1/auth/reset-password | Reset password with token | +| POST | /api/v1/auth/resend-verification | Resend verification email | ### Protected (requires Bearer token) @@ -87,6 +114,15 @@ The server starts at `http://localhost:8080`. | PATCH | /api/v1/todos/{id}/complete | Complete a todo | | GET | /api/v1/todos/search?q= | Search todos | +### Users + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | /api/v1/users | List users (paginated) | +| GET | /api/v1/users/{id} | Get a user | +| PUT | /api/v1/users/{id} | Update a user | +| DELETE | /api/v1/users/{id} | Delete a user | + ### Authorization (RBAC) | Method | Endpoint | Description | @@ -124,10 +160,12 @@ cmd/api/ - Entry point, router, swagger metadata internal/ core/domain/ - Shared interfaces (Entity, Cache, Messenger, TokenService, Logger) shared/ - Shared kernel (config, database, middleware, telemetry, utils) - infrastructure/ - Loosely coupled implementations (Redis, NATS, JWT, Zap) + infrastructure/ - Loosely coupled implementations (Redis, NATS, JWT, Zap, Email) + shared/ - Shared kernel (config, database, events, middleware, telemetry, utils) todo/ - Todo module (domain, application, infrastructure, interfaces) - authentication/ - Auth module (register, login, refresh, logout) + authentication/ - Auth module (register, login, refresh, logout, email verification) authorization/ - RBAC module (Casbin enforcer, roles, permissions) + user/ - User module (admin user CRUD) migrations/ - Database migrations docs/ - Documentation + generated Swagger output pkg/ - Public packages (password, slug) @@ -135,6 +173,88 @@ scripts/ - Utility scripts (migrate, seed) configs/ - Configuration files ``` +## Response Format + +All API responses share a unified envelope: + +```json +// Success (single resource) +{ + "success": true, + "data": { ... }, + "meta": null +} + +// Success (paginated list) +{ + "success": true, + "data": [ ... ], + "meta": { + "page": 1, + "per_page": 20, + "total": 100, + "total_pages": 5 + } +} + +// Error +{ + "success": false, + "data": null, + "error": { + "code": "VALIDATION_ERROR", + "message": "..." + } +} +``` + +All logs include `trace_id` and `span_id` extracted from the OpenTelemetry context. See [docs/API.md](docs/API.md) for the full list of error codes. + +## Monitoring & Observability + +The application exposes Prometheus metrics at `/metrics` and traces via OpenTelemetry OTLP to Jaeger. Full observability stack runs in Docker Compose: + +| Service | Port | Purpose | +|---------|------|---------| +| **Prometheus** | `9090` | Metrics collection and alerting | +| **Alertmanager** | `9093` | Alert routing (Email + Discord) | +| **Grafana** | `3000` | Dashboards (admin/admin) | +| **Jaeger** | `16686` | Distributed tracing UI | + +### Grafana Dashboards (provisioned automatically) + +1. **Go App RED** — request rate, error rate, p50/p95/p99 latency, active requests, goroutines, memory +2. **PostgreSQL** — connections, tps, cache hit ratio, deadlocks +3. **Redis** — hit rate, memory, connected clients, commands/s +4. **System Overview** — all-services health, combined metrics, latency heatmap + +### Prometheus Alerting Rules + +- `HighErrorRate` — 5xx > 5% over 5 minutes +- `HighLatency` — p99 latency > 1s over 5 minutes +- `ServiceDown` — target unreachable for 1 minute +- `HighMemoryUsage` — Go RSS > 500MB +- `DatabaseConnectionPoolExhaustion` — active connections > 40 + +Configure Discord webhook and email recipients via environment variables: + +```bash +DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... +ALERT_EMAIL_TO=admin@example.com +``` + +## Event-Driven Email + +Email sending is decoupled from the authentication service through domain events. The service publishes `UserRegistered`, `EmailVerified`, and `PasswordResetRequested` events; an `EmailHandler` subscribes and calls the configured mailer (SMTP, SendGrid, or console). The `EventBus` is interface-based so an async adapter (RabbitMQ, Kafka, etc.) can be swapped in without changing services or handlers. + +## CI/CD + +- **GitHub Actions CI** — lint, test (with PostgreSQL/Redis), build, and Docker image build on every push/PR +- **GitHub Actions CD** — build and push Docker image to GHCR, deploy to staging on `main`, deploy to production on version tags +- **Kubernetes** — Kustomize manifests in `k8s/base/` with staging/production overlays + +See [Deployment](docs/Deployment.md) for details. + ## Documentation - [Architecture](docs/Architecture.md) diff --git a/cmd/api/main.go b/cmd/api/main.go index 3a669a7..03ad941 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "fmt" "net/http" "os" @@ -10,23 +11,31 @@ import ( "time" "github.com/IDTS-LAB/go-codebase/internal/authentication" + authEventBus "github.com/IDTS-LAB/go-codebase/internal/authentication/infrastructure/eventbus" authHTTP "github.com/IDTS-LAB/go-codebase/internal/authentication/interfaces/http" - "github.com/IDTS-LAB/go-codebase/internal/authentication/application/service" "github.com/IDTS-LAB/go-codebase/internal/authorization" "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/casbin" authzHTTP "github.com/IDTS-LAB/go-codebase/internal/authorization/interfaces/http" "github.com/IDTS-LAB/go-codebase/internal/core/domain" "github.com/IDTS-LAB/go-codebase/internal/infrastructure/auth" "github.com/IDTS-LAB/go-codebase/internal/infrastructure/cache" + "github.com/IDTS-LAB/go-codebase/internal/infrastructure/email" "github.com/IDTS-LAB/go-codebase/internal/infrastructure/logger" "github.com/IDTS-LAB/go-codebase/internal/infrastructure/messaging" + "github.com/IDTS-LAB/go-codebase/internal/monitoring" + monitoringDomain "github.com/IDTS-LAB/go-codebase/internal/monitoring/domain" "github.com/IDTS-LAB/go-codebase/internal/shared/auditlog" "github.com/IDTS-LAB/go-codebase/internal/shared/config" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" "github.com/IDTS-LAB/go-codebase/internal/shared/database" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" "github.com/IDTS-LAB/go-codebase/internal/shared/router" "github.com/IDTS-LAB/go-codebase/internal/shared/telemetry" + "github.com/IDTS-LAB/go-codebase/internal/shared/tenantfilter" "github.com/IDTS-LAB/go-codebase/internal/shared/validator" + "github.com/IDTS-LAB/go-codebase/internal/tenant" + tenantHTTP "github.com/IDTS-LAB/go-codebase/internal/tenant/interfaces/http" "github.com/IDTS-LAB/go-codebase/internal/todo" todoHTTP "github.com/IDTS-LAB/go-codebase/internal/todo/interfaces/http" "github.com/IDTS-LAB/go-codebase/internal/user" @@ -36,28 +45,43 @@ import ( ) func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run() error { cfg, err := config.New() if err != nil { - fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err) - os.Exit(1) + return fmt.Errorf("load config: %w", err) } + fmt.Printf("[config] env=%s log_format=%s log_level=%s\n", cfg.App.Env, cfg.Log.Format, cfg.Log.Level) + var ( - authHandler *authHTTP.Handler - todoHandler *todoHTTP.Handler - authzHandler *authzHTTP.Handler - userHandler *userHTTP.Handler - enforcer *casbin.Enforcer - log domain.Logger - rdb *redis.Client - tokenSvc domain.TokenService - errorRepo *auditlog.Repository + authHandler *authHTTP.Handler + todoHandler *todoHTTP.Handler + authzHandler *authzHTTP.Handler + userHandler *userHTTP.Handler + tenantHandler *tenantHTTP.Handler + enforcer *casbin.Enforcer + log domain.Logger + db *sql.DB + rdb *redis.Client + tokenSvc domain.TokenService + errorRepo *auditlog.Repository + metricsRecorder monitoringDomain.MetricsRecorder + metricsHandler http.Handler + natsMessenger *messaging.NATSMessenger + debugNATSHandler http.Handler ) app := fx.New( fx.Supply(cfg), // Infrastructure + cqrs.Module, logger.Module, cache.Module, auth.Module, @@ -65,24 +89,26 @@ func main() { database.Module, telemetry.Module, validator.Module, + email.Module, // Modules + events.Module, authentication.Module, authorization.Module, + monitoring.Module, todo.Module, user.Module, + tenant.Module, // Shared fx.Provide(auditlog.NewRepository), + fx.Provide(func(cfg *config.Config) *tenantfilter.Config { + return &tenantfilter.Config{Enabled: cfg.Tenant.Enabled} + }), - // Denylist helper - fx.Invoke(func(authSvc *service.AuthenticationService, rdb *redis.Client, cfg *config.Config) { - if cfg.Auth.TokenDenylist { - authSvc.SetDenylist(func(ctx context.Context, jti string, ttl time.Duration) error { - return rdb.Set(ctx, "token:blacklist:"+jti, "1", ttl).Err() - }) - } - authSvc.SetLockoutConfig(cfg.Auth.MaxLoginAttempts, time.Duration(cfg.Auth.LockoutDuration)*time.Second) + // Event handlers + fx.Invoke(func(bus events.EventBus, eh *authEventBus.EmailHandler) { + eh.Register(bus) }), // Extract @@ -90,35 +116,47 @@ func main() { fx.Populate(&todoHandler), fx.Populate(&authzHandler), fx.Populate(&userHandler), + fx.Populate(&tenantHandler), fx.Populate(&enforcer), fx.Populate(&log), + fx.Populate(&db), fx.Populate(&rdb), fx.Populate(&tokenSvc), fx.Populate(&errorRepo), + fx.Populate(&metricsRecorder), + fx.Populate(&metricsHandler), + fx.Populate(&natsMessenger), ) + if natsMessenger != nil { + debugNATSHandler = natsMessenger.DebugHandler() + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() if err := app.Start(ctx); err != nil { - fmt.Fprintf(os.Stderr, "failed to start app: %v\n", err) - os.Exit(1) + return fmt.Errorf("start app: %w", err) } - mw := middleware.NewRegistry(tokenSvc, rdb, cfg, log, errorRepo, enforcer) + mw := middleware.NewRegistry(tokenSvc, rdb, cfg, log, errorRepo, enforcer, metricsRecorder) root := router.NewRouter(router.Handlers{ - Auth: authHTTP.NewRouter(authHandler, mw.Auth), - Todo: todoHTTP.NewRouter(todoHandler, mw.Auth, enforcer), - Authz: authzHTTP.NewRouter(authzHandler, mw.Auth, enforcer), - User: userHTTP.NewRouter(userHandler, mw.Auth, enforcer), - }, mw, log, cfg) + Auth: authHTTP.NewRouter(authHandler, mw.Auth), + Todo: todoHTTP.NewRouter(todoHandler, mw.Auth, enforcer), + Authz: authzHTTP.NewRouter(authzHandler, mw.Auth, enforcer), + User: userHTTP.NewRouter(userHandler, mw.Auth, enforcer), + Tenant: tenantHTTP.NewRouter(tenantHandler, mw.Auth, enforcer), + MetricsHandler: metricsHandler, + DebugNATSHandler: debugNATSHandler, + }, mw, log, cfg, db) srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Server.Port), Handler: root, ReadTimeout: time.Duration(cfg.Server.ReadTimeout) * time.Second, WriteTimeout: time.Duration(cfg.Server.WriteTimeout) * time.Second, + IdleTimeout: time.Duration(cfg.Server.IdleTimeout) * time.Second, } go func() { @@ -131,16 +169,16 @@ func main() { <-ctx.Done() - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15) + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) defer shutdownCancel() if err := srv.Shutdown(shutdownCtx); err != nil { - fmt.Fprintf(os.Stderr, "failed to stop app: %v\n", err) - os.Exit(1) + return fmt.Errorf("shutdown server: %w", err) } - if err := app.Stop(ctx); err != nil { - fmt.Fprintf(os.Stderr, "failed to stop app: %v\n", err) - os.Exit(1) + if err := app.Stop(context.Background()); err != nil { + return fmt.Errorf("stop app: %w", err) } + + return nil } diff --git a/cmd/api/swagger.go b/cmd/api/swagger.go index a14a5a3..583c95a 100644 --- a/cmd/api/swagger.go +++ b/cmd/api/swagger.go @@ -7,4 +7,4 @@ package main // @BasePath /api/v1 // @securityDefinitions.apikey BearerAuth // @in header -// @name Authorization \ No newline at end of file +// @name Authorization diff --git a/configs/config.yaml b/configs/config.yaml index 114eb26..42e719a 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -1,7 +1,8 @@ # ============================================================================= # Application # ============================================================================= -app_env: development +app: + env: development # ============================================================================= # Server @@ -21,7 +22,7 @@ database: port: 5432 user: postgres password: postgres - name: go_codebase + name: codebase sslmode: disable max_open_conns: 25 max_idle_conns: 5 @@ -36,24 +37,47 @@ redis: db: 0 pool_size: 10 +# ============================================================================= +# Event Bus +# ============================================================================= +events: + driver: memory + # ============================================================================= # NATS # ============================================================================= nats: url: nats://localhost:4222 + debug_endpoint: false + stream: + name: events + subjects: + - events.> + storage: file + retention: interest + consumer: + durable_name: event-bus + deliver_group: event-bus + ack_policy: explicit + max_deliver: -1 + ack_wait: 30 # ============================================================================= # Authentication & Security # ============================================================================= -jwt: +auth: secret: your-secret-key-change-in-production expiration: 3600 - -auth: max_login_attempts: 5 lockout_duration: 900 token_denylist: true +multitenancy: + enabled: false + tenant_header: "X-Tenant-ID" + tenant_jwt_claim: "tenant_id" + domain: "app.com" + cors: allowed_origins: ["*"] allowed_methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] @@ -84,7 +108,7 @@ log: telemetry: service_name: go-codebase - exporter_endpoint: http://localhost:4318 + exporter_endpoint: localhost:4318 sample_rate: 1.0 # ============================================================================= @@ -92,3 +116,20 @@ telemetry: # ============================================================================= asynq: redis_addr: localhost:6379 + +# ============================================================================= +# Email Service +# ============================================================================= +email: + provider: console + from: "no-reply@example.com" + from_name: "App" + frontend_url: "http://localhost:3000" + smtp: + host: localhost + port: 587 + username: "" + password: "" + use_tls: true + sendgrid: + api_key: "" diff --git a/deployments/alertmanager/alertmanager.yml b/deployments/alertmanager/alertmanager.yml new file mode 100644 index 0000000..37edaf9 --- /dev/null +++ b/deployments/alertmanager/alertmanager.yml @@ -0,0 +1,40 @@ +global: + resolve_timeout: 5m + smtp_smarthost: "${SMTP_HOST}:${SMTP_PORT}" + smtp_from: "${SMTP_FROM}" + smtp_auth_username: "${SMTP_USERNAME}" + smtp_auth_password: "${SMTP_PASSWORD}" + smtp_require_tls: true + +route: + group_by: ["alertname", "severity"] + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + receiver: "default" + routes: + - match: + severity: critical + receiver: "critical" + - match: + severity: warning + receiver: "warning" + +receivers: + - name: "default" + webhook_configs: + - url: "${DISCORD_WEBHOOK_URL}" + send_resolved: true + + - name: "critical" + email_configs: + - to: "${ALERT_EMAIL_TO}" + send_resolved: true + webhook_configs: + - url: "${DISCORD_WEBHOOK_URL}" + send_resolved: true + + - name: "warning" + webhook_configs: + - url: "${DISCORD_WEBHOOK_URL}" + send_resolved: false diff --git a/deployments/grafana/dashboards/comprehensive.json b/deployments/grafana/dashboards/comprehensive.json new file mode 100644 index 0000000..38a139a --- /dev/null +++ b/deployments/grafana/dashboards/comprehensive.json @@ -0,0 +1,225 @@ +{ + "title": "Go Codebase — Comprehensive", + "uid": "go-codebase-comprehensive", + "schemaVersion": 39, + "version": 1, + "timezone": "browser", + "editable": true, + "tags": ["go", "postgresql", "redis", "monitoring"], + "refresh": "10s", + "panels": [ + { + "title": "Service Uptime", + "type": "stat", + "gridPos": {"h": 4, "w": 3, "x": 0, "y": 0}, + "targets": [{"expr": "up{job=\"go-codebase\"}", "legendFormat": "API"}], + "reduceCalc": "lastNotNull", + "colorMode": "background", + "thresholds": [{"value": 0, "color": "red"}, {"value": 1, "color": "green"}] + }, + { + "title": "Request Rate", + "type": "stat", + "gridPos": {"h": 4, "w": 3, "x": 3, "y": 0}, + "targets": [{"expr": "sum(rate(http_requests_total[1m]))", "legendFormat": "rps"}], + "reduceCalc": "lastNotNull", + "colorMode": "background" + }, + { + "title": "Error Rate", + "type": "stat", + "gridPos": {"h": 4, "w": 3, "x": 6, "y": 0}, + "targets": [{"expr": "sum(rate(http_requests_total{status=~\"5..\"}[1m])) / sum(rate(http_requests_total[1m])) * 100", "legendFormat": "%"}], + "reduceCalc": "lastNotNull", + "colorMode": "background", + "thresholds": [{"value": 0, "color": "green"}, {"value": 5, "color": "orange"}, {"value": 10, "color": "red"}] + }, + { + "title": "p99 Latency", + "type": "stat", + "gridPos": {"h": 4, "w": 3, "x": 9, "y": 0}, + "targets": [{"expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))", "legendFormat": "p99"}], + "reduceCalc": "lastNotNull", + "colorMode": "background", + "thresholds": [{"value": 0, "color": "green"}, {"value": 0.5, "color": "orange"}, {"value": 1, "color": "red"}] + }, + { + "title": "Active Go Routines", + "type": "stat", + "gridPos": {"h": 4, "w": 3, "x": 12, "y": 0}, + "targets": [{"expr": "go_goroutines", "legendFormat": "goroutines"}], + "reduceCalc": "lastNotNull" + }, + { + "title": "Process Memory", + "type": "stat", + "gridPos": {"h": 4, "w": 3, "x": 15, "y": 0}, + "targets": [{"expr": "process_resident_memory_bytes", "legendFormat": "RSS"}], + "reduceCalc": "lastNotNull" + }, + { + "title": "DB Connections", + "type": "stat", + "gridPos": {"h": 4, "w": 3, "x": 18, "y": 0}, + "targets": [{"expr": "pg_stat_database_numbackends", "legendFormat": "postgres"}], + "reduceCalc": "lastNotNull" + }, + { + "title": "Redis Memory", + "type": "stat", + "gridPos": {"h": 4, "w": 3, "x": 21, "y": 0}, + "targets": [{"expr": "redis_memory_used_bytes", "legendFormat": "used"}], + "reduceCalc": "lastNotNull" + }, + { + "title": "Request Rate by Method", + "type": "timeseries", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 4}, + "targets": [{"expr": "sum(rate(http_requests_total[1m])) by (method)", "legendFormat": "{{method}}"}], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "Requests by Status", + "type": "timeseries", + "gridPos": {"h": 8, "w": 8, "x": 8, "y": 4}, + "targets": [ + {"expr": "sum(rate(http_requests_total{status=~\"2..\"}[5m])) by (status)", "legendFormat": "2xx"}, + {"expr": "sum(rate(http_requests_total{status=~\"4..\"}[5m])) by (status)", "legendFormat": "4xx"}, + {"expr": "sum(rate(http_requests_total{status=~\"5..\"}[5m])) by (status)", "legendFormat": "5xx"} + ], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "Error Rate (5xx %)", + "type": "timeseries", + "gridPos": {"h": 8, "w": 8, "x": 16, "y": 4}, + "targets": [{"expr": "rate(http_requests_total{status=~\"5..\"}[1m]) / rate(http_requests_total[1m]) * 100", "legendFormat": "error %"}], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "Active Requests", + "type": "timeseries", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 12}, + "targets": [{"expr": "http_requests_active", "legendFormat": "active"}], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "Latency Quantiles (p50, p95, p99)", + "type": "timeseries", + "gridPos": {"h": 8, "w": 8, "x": 8, "y": 12}, + "targets": [ + {"expr": "histogram_quantile(0.50, rate(http_request_duration_seconds_bucket[5m]))", "legendFormat": "p50"}, + {"expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))", "legendFormat": "p95"}, + {"expr": "histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))", "legendFormat": "p99"} + ], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "Latency Heatmap", + "type": "heatmap", + "gridPos": {"h": 8, "w": 8, "x": 16, "y": 12}, + "targets": [{"expr": "rate(http_request_duration_seconds_bucket[5m])"}] + }, + { + "title": "Go Goroutines", + "type": "timeseries", + "gridPos": {"h": 8, "w": 6, "x": 0, "y": 20}, + "targets": [{"expr": "go_goroutines", "legendFormat": "goroutines"}], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "Go Memory (RSS)", + "type": "timeseries", + "gridPos": {"h": 8, "w": 6, "x": 6, "y": 20}, + "targets": [{"expr": "process_resident_memory_bytes", "legendFormat": "RSS"}], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "Request Size", + "type": "timeseries", + "gridPos": {"h": 8, "w": 6, "x": 12, "y": 20}, + "targets": [{"expr": "rate(http_request_size_bytes_sum[5m]) / rate(http_request_size_bytes_count[5m])", "legendFormat": "avg req size"}], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "Response Size", + "type": "timeseries", + "gridPos": {"h": 8, "w": 6, "x": 18, "y": 20}, + "targets": [{"expr": "rate(http_response_size_bytes_sum[5m]) / rate(http_response_size_bytes_count[5m])", "legendFormat": "avg resp size"}], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "PostgreSQL — Active Connections", + "type": "timeseries", + "gridPos": {"h": 8, "w": 6, "x": 0, "y": 28}, + "targets": [{"expr": "pg_stat_database_numbackends", "legendFormat": "connections", "datasource": "Prometheus"}], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "PostgreSQL — Transactions/s", + "type": "timeseries", + "gridPos": {"h": 8, "w": 6, "x": 6, "y": 28}, + "targets": [{"expr": "rate(pg_stat_database_xact_commit[5m]) + rate(pg_stat_database_xact_rollback[5m])", "legendFormat": "tps"}], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "PostgreSQL — Cache Hit Ratio", + "type": "timeseries", + "gridPos": {"h": 8, "w": 6, "x": 12, "y": 28}, + "targets": [{"expr": "rate(pg_stat_database_blks_hit[5m]) / (rate(pg_stat_database_blks_hit[5m]) + rate(pg_stat_database_blks_read[5m])) * 100", "legendFormat": "hit %"}], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "PostgreSQL — Deadlocks", + "type": "timeseries", + "gridPos": {"h": 8, "w": 6, "x": 18, "y": 28}, + "targets": [{"expr": "rate(pg_stat_database_deadlocks[5m])", "legendFormat": "deadlocks"}], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "Redis — Cache Hit Rate", + "type": "timeseries", + "gridPos": {"h": 8, "w": 6, "x": 0, "y": 36}, + "targets": [{"expr": "rate(redis_keyspace_hits_total[5m]) / (rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m])) * 100", "legendFormat": "hit %"}], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "Redis — Memory Usage", + "type": "timeseries", + "gridPos": {"h": 8, "w": 6, "x": 6, "y": 36}, + "targets": [{"expr": "redis_memory_used_bytes", "legendFormat": "used"}], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "Redis — Connected Clients", + "type": "timeseries", + "gridPos": {"h": 8, "w": 6, "x": 12, "y": 36}, + "targets": [{"expr": "redis_connected_clients", "legendFormat": "clients"}], + "lines": true, + "fillOpacity": 20 + }, + { + "title": "Redis — Commands/s", + "type": "timeseries", + "gridPos": {"h": 8, "w": 6, "x": 18, "y": 36}, + "targets": [{"expr": "rate(redis_commands_total[5m])", "legendFormat": "cmd/s"}], + "lines": true, + "fillOpacity": 20 + } + ] +} \ No newline at end of file diff --git a/deployments/grafana/dashboards/dashboard.yml b/deployments/grafana/dashboards/dashboard.yml new file mode 100644 index 0000000..472ac26 --- /dev/null +++ b/deployments/grafana/dashboards/dashboard.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: "default" + orgId: 1 + folder: "" + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards diff --git a/deployments/grafana/dashboards/go-app-red.json b/deployments/grafana/dashboards/go-app-red.json new file mode 100644 index 0000000..e282759 --- /dev/null +++ b/deployments/grafana/dashboards/go-app-red.json @@ -0,0 +1,61 @@ +{ + "title": "Go App - RED Metrics", + "uid": "go-app-red", + "schemaVersion": 39, + "version": 1, + "timezone": "browser", + "panels": [ + { + "title": "Request Rate", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 0}, + "targets": [{"expr": "rate(http_requests_total[1m])", "legendFormat": "{{method}} {{path}} {{status}}"}], + "lines": true, + "fill": 1 + }, + { + "title": "Error Rate (5xx)", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 8, "y": 0}, + "targets": [{"expr": "rate(http_requests_total{status=~\"5..\"}[1m]) / rate(http_requests_total[1m]) * 100", "legendFormat": "error %"}], + "lines": true, + "fill": 1 + }, + { + "title": "Latency (p50, p95, p99)", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 16, "y": 0}, + "targets": [ + {"expr": "histogram_quantile(0.50, rate(http_request_duration_seconds_bucket[5m]))", "legendFormat": "p50"}, + {"expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))", "legendFormat": "p95"}, + {"expr": "histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))", "legendFormat": "p99"} + ], + "lines": true, + "fill": 1 + }, + { + "title": "Active Requests", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 8}, + "targets": [{"expr": "http_requests_active", "legendFormat": "active"}], + "lines": true, + "fill": 1 + }, + { + "title": "Go Routines", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 8, "y": 8}, + "targets": [{"expr": "go_goroutines", "legendFormat": "goroutines"}], + "lines": true, + "fill": 1 + }, + { + "title": "Memory Usage", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 16, "y": 8}, + "targets": [{"expr": "process_resident_memory_bytes", "legendFormat": "RSS"}], + "lines": true, + "fill": 1 + } + ] +} diff --git a/deployments/grafana/dashboards/nats.json b/deployments/grafana/dashboards/nats.json new file mode 100644 index 0000000..fa46b47 --- /dev/null +++ b/deployments/grafana/dashboards/nats.json @@ -0,0 +1,82 @@ +{ + "title": "NATS", + "uid": "nats", + "schemaVersion": 39, + "version": 1, + "timezone": "browser", + "panels": [ + { + "title": "Active Connections", + "type": "stat", + "gridPos": {"h": 4, "w": 4, "x": 0, "y": 0}, + "targets": [{"expr": "nats_connections", "legendFormat": ""}] + }, + { + "title": "Subscriptions", + "type": "stat", + "gridPos": {"h": 4, "w": 4, "x": 4, "y": 0}, + "targets": [{"expr": "nats_subscriptions", "legendFormat": ""}] + }, + { + "title": "Uptime", + "type": "stat", + "gridPos": {"h": 4, "w": 4, "x": 8, "y": 0}, + "targets": [{"expr": "nats_uptime_seconds", "legendFormat": ""}], + "fieldConfig": {"defaults": {"unit": "s"}} + }, + { + "title": "Messages In/Out", + "type": "graph", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 4}, + "targets": [ + {"expr": "rate(nats_messages_sent_total[5m])", "legendFormat": "sent"}, + {"expr": "rate(nats_messages_received_total[5m])", "legendFormat": "received"} + ], + "lines": true, + "fill": 1 + }, + { + "title": "Bytes In/Out", + "type": "graph", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 4}, + "targets": [ + {"expr": "rate(nats_out_bytes_total[5m])", "legendFormat": "out"}, + {"expr": "rate(nats_in_bytes_total[5m])", "legendFormat": "in"} + ], + "lines": true, + "fill": 1 + }, + { + "title": "Publish Rate by Subject", + "type": "bargauge", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 12}, + "targets": [{"expr": "rate(nats_published_total[5m])", "legendFormat": "{{subject}}"}] + }, + { + "title": "Receive Rate by Subject", + "type": "bargauge", + "gridPos": {"h": 8, "w": 8, "x": 8, "y": 12}, + "targets": [{"expr": "rate(nats_received_total[5m])", "legendFormat": "{{subject}}"}] + }, + { + "title": "Data Volume by Subject", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 16, "y": 12}, + "targets": [{"expr": "rate(nats_publish_bytes_total[5m])", "legendFormat": "{{subject}}"}], + "lines": true, + "fill": 1 + }, + { + "title": "Recent Messages", + "type": "table", + "gridPos": {"h": 8, "w": 24, "x": 0, "y": 20}, + "targets": [{ + "refId": "A", + "type": "json", + "url": "http://api:8080/debug/nats", + "fields": ["timestamp", "subject", "payload"] + }], + "datasource": "Infinity" + } + ] +} diff --git a/deployments/grafana/dashboards/overview.json b/deployments/grafana/dashboards/overview.json new file mode 100644 index 0000000..11c3c56 --- /dev/null +++ b/deployments/grafana/dashboards/overview.json @@ -0,0 +1,79 @@ +{ + "title": "System Overview", + "uid": "overview", + "schemaVersion": 39, + "version": 1, + "timezone": "browser", + "panels": [ + { + "title": "Service Uptime", + "type": "stat", + "gridPos": {"h": 4, "w": 4, "x": 0, "y": 0}, + "targets": [{"expr": "up{job=\"go-codebase\"}", "legendFormat": "API"}], + "reduceCalc": "lastNotNull" + }, + { + "title": "Request Rate", + "type": "stat", + "gridPos": {"h": 4, "w": 4, "x": 4, "y": 0}, + "targets": [{"expr": "sum(rate(http_requests_total[1m]))", "legendFormat": "rps"}], + "reduceCalc": "lastNotNull" + }, + { + "title": "Error Rate", + "type": "stat", + "gridPos": {"h": 4, "w": 4, "x": 8, "y": 0}, + "targets": [{"expr": "sum(rate(http_requests_total{status=~\"5..\"}[1m])) / sum(rate(http_requests_total[1m])) * 100", "legendFormat": "%"}], + "reduceCalc": "lastNotNull" + }, + { + "title": "p99 Latency", + "type": "stat", + "gridPos": {"h": 4, "w": 4, "x": 12, "y": 0}, + "targets": [{"expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))", "legendFormat": "p99"}], + "reduceCalc": "lastNotNull" + }, + { + "title": "Requests by Status", + "type": "graph", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 4}, + "targets": [ + {"expr": "sum(rate(http_requests_total{status=~\"2..\"}[5m])) by (status)", "legendFormat": "2xx"}, + {"expr": "sum(rate(http_requests_total{status=~\"4..\"}[5m])) by (status)", "legendFormat": "4xx"}, + {"expr": "sum(rate(http_requests_total{status=~\"5..\"}[5m])) by (status)", "legendFormat": "5xx"} + ], + "lines": true, + "fill": 1 + }, + { + "title": "Latency Heatmap", + "type": "heatmap", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 4}, + "targets": [{"expr": "rate(http_request_duration_seconds_bucket[5m])"}] + }, + { + "title": "Database Connections", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 12}, + "targets": [{"expr": "pg_stat_database_numbackends", "legendFormat": "postgres"}], + "lines": true, + "fill": 1 + }, + { + "title": "Redis Hit Rate", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 8, "y": 12}, + "targets": [{"expr": "rate(redis_keyspace_hits_total[5m]) / (rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m])) * 100", "legendFormat": "hit %"}], + "lines": true, + "fill": 1 + }, + { + "title": "Goroutines", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 16, "y": 12}, + "targets": [{"expr": "go_goroutines", "legendFormat": "goroutines"}], + "lines": true, + "fill": 1 + } + ] +} diff --git a/deployments/grafana/dashboards/postgresql.json b/deployments/grafana/dashboards/postgresql.json new file mode 100644 index 0000000..9f5beab --- /dev/null +++ b/deployments/grafana/dashboards/postgresql.json @@ -0,0 +1,41 @@ +{ + "title": "PostgreSQL", + "uid": "postgresql", + "schemaVersion": 39, + "version": 1, + "timezone": "browser", + "panels": [ + { + "title": "Active Connections", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 0}, + "targets": [{"expr": "pg_stat_database_numbackends", "legendFormat": "connections"}], + "lines": true, + "fill": 1 + }, + { + "title": "Transactions Per Second", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 8, "y": 0}, + "targets": [{"expr": "rate(pg_stat_database_xact_commit[5m]) + rate(pg_stat_database_xact_rollback[5m])", "legendFormat": "tps"}], + "lines": true, + "fill": 1 + }, + { + "title": "Cache Hit Ratio", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 16, "y": 0}, + "targets": [{"expr": "rate(pg_stat_database_blks_hit[5m]) / (rate(pg_stat_database_blks_hit[5m]) + rate(pg_stat_database_blks_read[5m])) * 100", "legendFormat": "hit %"}], + "lines": true, + "fill": 1 + }, + { + "title": "Deadlocks", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 8}, + "targets": [{"expr": "rate(pg_stat_database_deadlocks[5m])", "legendFormat": "deadlocks"}], + "lines": true, + "fill": 1 + } + ] +} diff --git a/deployments/grafana/dashboards/redis.json b/deployments/grafana/dashboards/redis.json new file mode 100644 index 0000000..2f328ea --- /dev/null +++ b/deployments/grafana/dashboards/redis.json @@ -0,0 +1,41 @@ +{ + "title": "Redis", + "uid": "redis", + "schemaVersion": 39, + "version": 1, + "timezone": "browser", + "panels": [ + { + "title": "Cache Hit Rate", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 0}, + "targets": [{"expr": "rate(redis_keyspace_hits_total[5m]) / (rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m])) * 100", "legendFormat": "hit %"}], + "lines": true, + "fill": 1 + }, + { + "title": "Memory Usage", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 8, "y": 0}, + "targets": [{"expr": "redis_memory_used_bytes", "legendFormat": "used"}], + "lines": true, + "fill": 1 + }, + { + "title": "Connected Clients", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 16, "y": 0}, + "targets": [{"expr": "redis_connected_clients", "legendFormat": "clients"}], + "lines": true, + "fill": 1 + }, + { + "title": "Commands Per Second", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 8}, + "targets": [{"expr": "rate(redis_commands_total[5m])", "legendFormat": "cmd/s"}], + "lines": true, + "fill": 1 + } + ] +} diff --git a/deployments/grafana/datasources/datasources.yml b/deployments/grafana/datasources/datasources.yml new file mode 100644 index 0000000..d3bdd7f --- /dev/null +++ b/deployments/grafana/datasources/datasources.yml @@ -0,0 +1,22 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + + - name: Jaeger + type: jaeger + access: proxy + url: http://jaeger:16686 + editable: false + + - name: Infinity + type: yesoreyeram-infinity-datasource + access: proxy + url: http://api:8080 + isDefault: false + editable: false diff --git a/deployments/prometheus/alerts.yml b/deployments/prometheus/alerts.yml new file mode 100644 index 0000000..47629c6 --- /dev/null +++ b/deployments/prometheus/alerts.yml @@ -0,0 +1,48 @@ +groups: + - name: go-codebase + interval: 30s + rules: + - alert: HighErrorRate + expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05 + for: 5m + labels: + severity: critical + annotations: + summary: "High HTTP 5xx error rate" + description: "Error rate is {{ $value | humanizePercentage }} for the last 5 minutes." + + - alert: HighLatency + expr: histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])) > 1 + for: 5m + labels: + severity: warning + annotations: + summary: "High p99 latency" + description: "p99 latency is {{ $value }}s for the last 5 minutes." + + - alert: ServiceDown + expr: up == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "Service {{ $labels.instance }} is down" + description: "{{ $labels.job }} ({{ $labels.instance }}) has been unreachable for 1 minute." + + - alert: HighMemoryUsage + expr: process_resident_memory_bytes > 500 * 1024 * 1024 + for: 5m + labels: + severity: warning + annotations: + summary: "High memory usage" + description: "Go process RSS is {{ $value | humanize1024 }} bytes." + + - alert: DatabaseConnectionPoolExhaustion + expr: pg_stat_database_numbackends > 40 + for: 5m + labels: + severity: warning + annotations: + summary: "Database connection pool near limit" + description: "Active connections: {{ $value }}" diff --git a/deployments/prometheus/prometheus.dev.yml b/deployments/prometheus/prometheus.dev.yml new file mode 100644 index 0000000..4637b42 --- /dev/null +++ b/deployments/prometheus/prometheus.dev.yml @@ -0,0 +1,28 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +alerting: + alertmanagers: + - static_configs: + - targets: ["alertmanager:9093"] + +rule_files: + - "alerts.yml" + +scrape_configs: + - job_name: "go-codebase" + static_configs: + - targets: ["host.docker.internal:8080"] + + - job_name: "redis" + static_configs: + - targets: ["redis_exporter:9121"] + + - job_name: "nats" + static_configs: + - targets: ["nats:8222"] + + - job_name: "alertmanager" + static_configs: + - targets: ["alertmanager:9093"] diff --git a/deployments/prometheus/prometheus.yml b/deployments/prometheus/prometheus.yml new file mode 100644 index 0000000..13a171c --- /dev/null +++ b/deployments/prometheus/prometheus.yml @@ -0,0 +1,32 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +alerting: + alertmanagers: + - static_configs: + - targets: ["alertmanager:9093"] + +rule_files: + - "alerts.yml" + +scrape_configs: + - job_name: "go-codebase" + static_configs: + - targets: ["api:8080"] + + - job_name: "postgresql" + static_configs: + - targets: ["postgres_exporter:9187"] + + - job_name: "redis" + static_configs: + - targets: ["redis_exporter:9121"] + + - job_name: "nats" + static_configs: + - targets: ["nats:8222"] + + - job_name: "alertmanager" + static_configs: + - targets: ["alertmanager:9093"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..fbf4f31 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,117 @@ +version: "3.8" + +# Development infrastructure only — runs API and PostgreSQL locally. +# Start with: docker compose -f docker-compose.dev.yml up -d +# Then run: make run + +services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - dev-network + + redis_exporter: + image: oliver006/redis_exporter:latest + ports: + - "9121:9121" + environment: + REDIS_ADDR: redis:6379 + depends_on: + redis: + condition: service_healthy + networks: + - dev-network + + postgres_exporter: + image: prometheuscommunity/postgres-exporter:latest + ports: + - "9187:9187" + environment: + DATA_SOURCE_NAME: "postgresql://postgres:postgres@host.docker.internal:5432/go_codebase?sslmode=disable" + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - dev-network + + nats: + image: nats:2-alpine + ports: + - "4222:4222" + - "8222:8222" + command: ["--http_port", "8222", "-js"] + networks: + - dev-network + + jaeger: + image: jaegertracing/all-in-one:latest + ports: + - "16686:16686" + - "4318:4318" + environment: + - COLLECTOR_OTLP_ENABLED=true + networks: + - dev-network + + prometheus: + image: prom/prometheus:v2.51.0 + ports: + - "9090:9090" + volumes: + - ./deployments/prometheus/prometheus.dev.yml:/etc/prometheus/prometheus.yml + - ./deployments/prometheus/alerts.yml:/etc/prometheus/alerts.yml + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - dev-network + + alertmanager: + image: prom/alertmanager:v0.27.0 + ports: + - "9093:9093" + volumes: + - ./deployments/alertmanager:/etc/alertmanager + command: + - "--config.file=/etc/alertmanager/alertmanager.yml" + environment: + - SMTP_HOST=${SMTP_HOST:-mail.example.com} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_FROM=${SMTP_FROM:-alert@example.com} + - SMTP_USERNAME=${SMTP_USERNAME:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL:-} + - ALERT_EMAIL_TO=${ALERT_EMAIL_TO:-admin@example.com} + networks: + - dev-network + + grafana: + image: grafana/grafana:10.4.0 + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_INSTALL_PLUGINS=yesoreyeram-infinity-datasource + volumes: + - ./deployments/grafana/datasources:/etc/grafana/provisioning/datasources + - ./deployments/grafana/dashboards:/etc/grafana/provisioning/dashboards + - grafana_data:/var/lib/grafana + depends_on: + - prometheus + networks: + - dev-network + +volumes: + redis_data: + grafana_data: + +networks: + dev-network: + driver: bridge diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..a6e5e52 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,114 @@ +version: "3.8" + +services: + api: + build: + context: . + dockerfile: Dockerfile + image: ghcr.io/idts-lab/go-codebase:latest + ports: + - "8080:8080" + environment: + - APP_ENV=production + - SERVER_PORT=8080 + - DB_HOST=${DB_HOST:-postgres} + - DB_PORT=${DB_PORT:-5432} + - DB_USER=${DB_USER:-postgres} + - DB_PASSWORD=${DB_PASSWORD} + - DB_NAME=${DB_NAME:-go_codebase} + - DB_SSLMODE=${DB_SSLMODE:-disable} + - REDIS_ADDR=${REDIS_ADDR:-redis:6379} + - NATS_URL=${NATS_URL:-nats://nats:4222} + - JWT_SECRET=${JWT_SECRET} + - OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-go-codebase} + - OTEL_EXPORTER_ENDPOINT=${OTEL_EXPORTER_ENDPOINT:-} + - OTEL_SAMPLE_RATE=${OTEL_SAMPLE_RATE:-1.0} + - EMAIL_PROVIDER=${EMAIL_PROVIDER:-console} + - EMAIL_FROM=${EMAIL_FROM:-no-reply@example.com} + - EMAIL_FROM_NAME=${EMAIL_FROM_NAME:-App} + - FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000} + - SMTP_HOST=${SMTP_HOST:-} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_USERNAME=${SMTP_USERNAME:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_USE_TLS=${SMTP_USE_TLS:-true} + - SENDGRID_API_KEY=${SENDGRID_API_KEY:-} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + nats: + condition: service_started + networks: + - app-network + restart: unless-stopped + deploy: + replicas: 2 + resources: + limits: + cpus: '1' + memory: 512M + reservations: + cpus: '0.25' + memory: 128M + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_NAME:-go_codebase} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - app-network + restart: unless-stopped + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - app-network + restart: unless-stopped + + nats: + image: nats:2-alpine + command: ["--http_port", "8222"] + networks: + - app-network + restart: unless-stopped + + migrate: + image: ghcr.io/idts-lab/go-codebase:latest + command: ["sh", "-c", "sleep 5 && goose -dir migrations up"] + environment: + - GOOSE_DRIVER=postgres + - GOOSE_DBSTRING=postgres://${DB_USER:-postgres}:${DB_PASSWORD}@${DB_HOST:-postgres}:${DB_PORT:-5432}/${DB_NAME:-go_codebase}?sslmode=${DB_SSLMODE:-disable} + depends_on: + postgres: + condition: service_healthy + networks: + - app-network + restart: on-failure + profiles: + - migrate + +volumes: + postgres_data: + redis_data: + +networks: + app-network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 66f4594..acbca69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: - DB_NAME=go_codebase - REDIS_ADDR=redis:6379 - NATS_URL=nats://nats:4222 + - NATS_DEBUG_ENDPOINT=true - JWT_SECRET=your-secret-key-change-in-production - OTEL_EXPORTER_ENDPOINT=http://jaeger:4318 depends_on: @@ -43,6 +44,18 @@ services: networks: - app-network + postgres_exporter: + image: prometheuscommunity/postgres-exporter:latest + ports: + - "9187:9187" + environment: + DATA_SOURCE_NAME: "postgresql://postgres:postgres@postgres:5432/go_codebase?sslmode=disable" + depends_on: + postgres: + condition: service_healthy + networks: + - app-network + redis: image: redis:7-alpine ports: @@ -57,17 +70,29 @@ services: networks: - app-network + redis_exporter: + image: oliver006/redis_exporter:latest + ports: + - "9121:9121" + environment: + REDIS_ADDR: redis:6379 + depends_on: + redis: + condition: service_healthy + networks: + - app-network + nats: image: nats:2-alpine ports: - "4222:4222" - "8222:8222" - command: ["--http_port", "8222"] + command: ["--http_port", "8222", "-js"] networks: - app-network jaeger: - image: jaegertracing/all-in-one:1.61 + image: jaegertracing/all-in-one:latest ports: - "16686:16686" - "4318:4318" @@ -81,7 +106,26 @@ services: ports: - "9090:9090" volumes: - - ./deployments/prometheus.yml:/etc/prometheus/prometheus.yml + - ./deployments/prometheus:/etc/prometheus + networks: + - app-network + + alertmanager: + image: prom/alertmanager:v0.27.0 + ports: + - "9093:9093" + volumes: + - ./deployments/alertmanager:/etc/alertmanager + command: + - "--config.file=/etc/alertmanager/alertmanager.yml" + environment: + - SMTP_HOST=${SMTP_HOST:-mail.example.com} + - SMTP_PORT=${SMTP_PORT:-587} + - SMTP_FROM=${SMTP_FROM:-alert@example.com} + - SMTP_USERNAME=${SMTP_USERNAME:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL:-} + - ALERT_EMAIL_TO=${ALERT_EMAIL_TO:-admin@example.com} networks: - app-network @@ -91,8 +135,13 @@ services: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_INSTALL_PLUGINS=yesoreyeram-infinity-datasource volumes: + - ./deployments/grafana/datasources:/etc/grafana/provisioning/datasources + - ./deployments/grafana/dashboards:/etc/grafana/provisioning/dashboards - grafana_data:/var/lib/grafana + depends_on: + - prometheus networks: - app-network diff --git a/docs/API.md b/docs/API.md index bf84da6..aacc917 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,5 +1,41 @@ # API Documentation +## Response Format + +All API responses follow this structure: + +| Field | Type | Description | +|-------|------|-------------| +| `success` | bool | Always present. `true` for success, `false` for error. | +| `data` | any | Response payload. `null` on errors. | +| `meta` | object or null | Pagination metadata. `null` for single-resource responses. | +| `error` | object or omitted | Error details. Present only on errors. | + +### Pagination + +Paginated list endpoints return `meta`: + +| Field | Type | Description | +|-------|------|-------------| +| `page` | int | Current page number | +| `per_page` | int | Items per page | +| `total` | int | Total items across all pages | +| `total_pages` | int | Total number of pages | + +### Error Codes + +| HTTP | Code | Description | +|------|------|-------------| +| 400 | `VALIDATION_ERROR` | Invalid request body or parameters | +| 401 | `UNAUTHORIZED` | Missing, invalid, or expired token | +| 403 | `FORBIDDEN` | Insufficient permissions | +| 403 | `ACCOUNT_LOCKED` | Account temporarily locked | +| 403 | `EMAIL_NOT_VERIFIED` | Email not verified | +| 404 | `NOT_FOUND` | Resource not found | +| 409 | `CONFLICT` | Duplicate or state conflict | +| 429 | `RATE_LIMITED` | Rate limit exceeded | +| 500 | `INTERNAL_ERROR` | Unexpected server error | + ## Base URL ``` @@ -49,7 +85,8 @@ Authorization: Bearer ```json { "success": true, - "data": { ... } + "data": { ... }, + "meta": null } ``` @@ -58,6 +95,7 @@ Authorization: Bearer ```json { "success": false, + "data": null, "error": { "code": "ERROR_CODE", "message": "Human readable message" @@ -283,11 +321,12 @@ Response (200): ```json { "success": true, - "data": { - "todos": [...], - "total": 100, + "data": [...], + "meta": { "page": 1, - "limit": 20 + "per_page": 20, + "total": 100, + "total_pages": 5 } } ``` diff --git a/docs/Architecture.md b/docs/Architecture.md index 062c055..2a59445 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -88,6 +88,45 @@ Casbin-based RBAC with database-backed policies: - Middleware enforces permissions on protected routes - Check permission endpoint for runtime validation +## Event-Driven & Response Handling + +### Event-Driven Email + +Services publish domain events to an `EventBus` interface (in-memory synchronous by default, swappable for RabbitMQ/Kafka). An `EmailHandler` subscribes to auth domain events (`auth.user.registered`, `auth.user.email_verified`, `auth.user.password_reset_requested`) and calls the appropriate mailer method. + +Flow: `Service → EventBus.Publish() → EmailHandler → domain.Emailer.Send*()` + +### Event Error Handling + +A `LoggingEventBus` decorator wraps the concrete bus and logs every publish failure through `domain.Logger`. Event handlers return errors instead of swallowing them, so mailer failures (SMTP down, SendGrid error, etc.) are recorded without breaking the originating HTTP request. Services discard the returned publish error after the decorator has logged it, keeping side effects best-effort. + +### OpenTelemetry Tracing + +Every HTTP request gets an OpenTelemetry span via a Chi middleware. The middleware: +- Extracts incoming W3C trace context (`traceparent`/`baggage` headers) +- Starts a span named ` ` +- Records the response status code +- Marks the span as error for 4xx/5xx responses + +The `ZapLogger` extracts `trace_id` and `span_id` from the context and attaches them to every log entry, so logs and traces are correlated. Panics and 5xx errors are recorded as exceptions on the current span. + +### API Response Format + +All HTTP responses use a unified envelope: + +```json +// Success (single) +{"success": true, "data": {...}, "meta": null} + +// Success (paginated list) +{"success": true, "data": [...], "meta": {"page": 1, "per_page": 20, "total": 100, "total_pages": 5}} + +// Error +{"success": false, "data": null, "error": {"code": "VALIDATION_ERROR", "message": "..."}} +``` + +Errors are mapped to HTTP status codes via `utils.MapError`, which translates `domain.ErrNotFound`, `domain.ErrConflict`, etc. + ## Module Registration Every module follows this pattern: diff --git a/docs/Deployment.md b/docs/Deployment.md index 65a8639..8ed44c7 100644 --- a/docs/Deployment.md +++ b/docs/Deployment.md @@ -2,14 +2,31 @@ ## Docker -### Build and Run +### Local Development ```bash -# Build and start all services +# Build and start all services (dev stack with observability) make docker-up # Stop all services make docker-down + +# Build the image only +make docker-build +``` + +### Production Compose + +```bash +# Copy and edit environment variables +cp .env.example .env +# Edit .env with production values (especially DB_PASSWORD, JWT_SECRET, SMTP_*) + +# Start production stack +docker compose -f docker-compose.prod.yml up -d + +# Run migrations +docker compose -f docker-compose.prod.yml --profile migrate run --rm migrate ``` ### Services @@ -28,7 +45,11 @@ make docker-down ### Environment Variables -Set all required environment variables (see `.env.example`). +Set all required environment variables (see `.env.example`). Critical secrets: + +- `JWT_SECRET` — must be changed from default in production +- `DB_PASSWORD` — strong database password +- `SMTP_PASSWORD` / `SENDGRID_API_KEY` — email provider credentials ### Database @@ -49,6 +70,52 @@ make build ./bin/go-codebase ``` +## CI/CD + +### GitHub Actions + +Two workflows are defined in `.github/workflows/`: + +#### CI (`ci.yml`) + +Runs on every push/PR to `main` and `development`: +- Lint with `golangci-lint` +- Run tests with race detection against PostgreSQL and Redis services +- Build the Go binary +- Build the Docker image + +#### CD (`cd.yml`) + +Runs on pushes to `main` and version tags: +- Builds and pushes Docker image to GitHub Container Registry (`ghcr.io/idts-lab/go-codebase`) +- Tags images with branch, tag, and short SHA +- Staging deployment job (triggered on `main` push) +- Production deployment job (triggered on `v*.*.*` tags) + +### Required Secrets + +No repository secrets are required for CI or image push (uses `GITHUB_TOKEN`). Add your own secrets if deployment jobs need SSH keys, kubeconfigs, or cloud credentials. + +## Kubernetes + +Base manifests are in `k8s/base/` with staging/production overlays in `k8s/overlays/`. + +### Deploy with Kustomize + +```bash +# Staging +kubectl apply -k k8s/overlays/staging + +# Production +kubectl apply -k k8s/overlays/production +``` + +### Before deploying + +1. Update `k8s/base/secret.yaml` with real credentials, or use an external secret manager. +2. Update ingress hosts in overlays to your actual domains. +3. Ensure cert-manager is installed for TLS, or remove the `cert-manager.io/cluster-issuer` annotation. + ## Health Checks - **Health**: `GET /health` - Returns 200 if service is running @@ -59,4 +126,4 @@ make build - **Swagger UI**: http://localhost:8080/swagger/index.html - Interactive API docs - **Jaeger**: http://localhost:16686 - Distributed tracing - **Prometheus**: http://localhost:9090 - Metrics -- **Grafana**: http://localhost:3000 - Dashboards (admin/admin) +- **Grafana**: http://localhost:3000 - Dashboards diff --git a/docs/FolderStructure.md b/docs/FolderStructure.md index 4072787..3d037ae 100644 --- a/docs/FolderStructure.md +++ b/docs/FolderStructure.md @@ -9,9 +9,18 @@ cmd/ configs/ # Configuration files config.yaml +.github/ # GitHub Actions workflows + workflows/ + ci.yml # CI pipeline + cd.yml # CD pipeline + deployments/ # Deployment configs prometheus.yml +k8s/ # Kubernetes manifests + base/ # Base Kustomize resources + overlays/ # Environment overlays (staging, production) + docs/ # Documentation README.md Architecture.md @@ -51,6 +60,7 @@ internal/ shared/ # Shared kernel (cross-module) config/ # Koanf configuration loading database/ # PostgreSQL connection, sqlc, goose + events/ # EventBus interface + InMemoryEventBus + LoggingEventBus + Fx module middleware/ # HTTP middleware recovery.go # Panic recovery request_id.go # Request ID propagation @@ -103,6 +113,7 @@ internal/ entity/ # Entities (User, RefreshToken) repository/ # Repository interfaces service/ # Auth domain service + event/ # Domain events (UserRegistered, EmailVerified, PasswordResetRequested) application/ # Use cases dto/ # DTOs (RegisterRequest, LoginRequest, TokenResponse) @@ -110,6 +121,7 @@ internal/ infrastructure/ # External implementations persistence/ # SQLC repositories + eventbus/ # Event handler implementations (EmailHandler) interfaces/ # Delivery mechanisms http/ # HTTP handlers (Register, Login, Refresh, Logout, Me) diff --git a/docs/README.md b/docs/README.md index 4009ee9..5aba0c6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -60,6 +60,22 @@ make run | GET | /health | Health check | | GET | /ready | Readiness check | +## Event Bus + +The EventBus is switchable between in-memory and NATS JetStream via config: + +```yaml +events: + driver: memory # "memory" (default) or "nats" +``` + +| Driver | Delivery | Persistence | Cross-instance | +|--------|----------|-------------|----------------| +| `memory` | Synchronous, at-most-once | None | No | +| `nats` | At-least-once via Ack/Nak | File-backed stream | Queue group (one worker per message) | + +When `driver: nats`, events are published to a JetStream stream (`events.>`) and consumed via a push consumer with queue group `event-bus`. Handlers that return an error trigger `Nak()`, causing infinite redelivery. + ## Project Structure See [FolderStructure.md](FolderStructure.md) diff --git a/docs/superpowers/plans/2026-07-09-email-service.md b/docs/superpowers/plans/2026-07-09-email-service.md new file mode 100644 index 0000000..3803a9b --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-email-service.md @@ -0,0 +1,1273 @@ +# Email Service Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a loosely-coupled email service with HTML templates for user verification, password reset, welcome, and invite emails with swappable providers. + +**Architecture:** Domain interface (`domain.Emailer`) with three provider implementations (console, SMTP, SendGrid). Templates embedded via `go:embed`. User entity extended with verification/reset fields. New auth endpoints for verification and password reset flows. + +**Tech Stack:** Go, html/template, embed.FS, net/smtp, SendGrid v3 API, godotenv/koanf (existing config) + +## Global Constraints + +- Follow existing codebase patterns (DDD, Fx modules, clean architecture) +- Email provider selected via `EMAIL_PROVIDER` env var (default: `console`) +- Templates embedded in binary (no external file dependencies) +- All new code must have tests +- Frequent atomic commits + +--- + +## File Structure + +| File | Purpose | +|------|---------| +| `internal/core/domain/email.go` | `Emailer` interface | +| `internal/shared/config/config.go` | Add `Email` config struct | +| `internal/infrastructure/email/email.go` | Factory function | +| `internal/infrastructure/email/console.go` | Console provider | +| `internal/infrastructure/email/smtp.go` | SMTP provider | +| `internal/infrastructure/email/sendgrid.go` | SendGrid provider | +| `internal/infrastructure/email/templates/verification.html` | Verification email template | +| `internal/infrastructure/email/templates/password_reset.html` | Password reset template | +| `internal/infrastructure/email/templates/welcome.html` | Welcome email template | +| `internal/infrastructure/email/templates/invite.html` | Invite email template | +| `internal/infrastructure/email/email_test.go` | Provider tests | +| `internal/authentication/domain/entity/user.go` | Add verification/reset fields | +| `internal/authentication/application/service/authentication_service.go` | Modify register/login, add reset flows | +| `internal/authentication/application/dto/authentication_dto.go` | Add new DTOs | +| `internal/authentication/interfaces/http/handlers.go` | Add new endpoints | +| `internal/authentication/interfaces/http/routes.go` | Add new routes | +| `internal/authentication/module.go` | Wire emailer | +| `migrations/005_add_email_verification.sql` | DB migration | +| `cmd/api/main.go` | Wire email module | +| `configs/config.yaml` | Add email config section | + +--- + +### Task 1: Domain Interface + Config + +**Files:** +- Create: `internal/core/domain/email.go` +- Modify: `internal/shared/config/config.go` +- Modify: `configs/config.yaml` + +**Interfaces:** +- Produces: `domain.Emailer` interface, `config.Email` struct + +- [ ] **Step 1: Create the Emailer interface** + +```go +// internal/core/domain/email.go +package domain + +type Emailer interface { + SendVerification(to, name, token string) error + SendPasswordReset(to, name, token string) error + SendWelcome(to, name string) error + SendInvite(to, name, inviterName string) error +} +``` + +- [ ] **Step 2: Add Email config to config.go** + +Add to `internal/shared/config/config.go`: + +```go +type EmailConfig struct { + Provider string `koanf:"provider"` + From string `koanf:"from"` + FromName string `koanf:"from_name"` + FrontendURL string `koanf:"frontend_url"` + SMTP SMTPConfig `koanf:"smtp"` + SendGrid SendGridConfig `koanf:"sendgrid"` +} + +type SMTPConfig struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + Username string `koanf:"username"` + Password string `koanf:"password"` + UseTLS bool `koanf:"use_tls"` +} + +type SendGridConfig struct { + APIKey string `koanf:"api_key"` +} +``` + +Add `Email EmailConfig` field to the `Config` struct. Add env override tags: `EMAIL_PROVIDER`, `EMAIL_FROM`, `EMAIL_FROM_NAME`, `FRONTEND_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_USE_TLS`, `SENDGRID_API_KEY`. + +- [ ] **Step 3: Add default email config** + +In the config defaults section, add: + +```go +cfg.Email = EmailConfig{ + Provider: "console", + From: "no-reply@example.com", + FromName: "App", + FrontendURL: "http://localhost:3000", + SMTP: SMTPConfig{ + Host: "localhost", + Port: 587, + UseTLS: true, + }, +} +``` + +- [ ] **Step 4: Add email section to configs/config.yaml** + +```yaml +email: + provider: console + from: "no-reply@example.com" + from_name: "App" + frontend_url: "http://localhost:3000" + smtp: + host: localhost + port: 587 + username: "" + password: "" + use_tls: true + sendgrid: + api_key: "" +``` + +- [ ] **Step 5: Verify build** + +Run: `go build ./...` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/core/domain/email.go internal/shared/config/config.go configs/config.yaml +git commit -m "email: add domain interface and config" +``` + +--- + +### Task 2: Console Provider + +**Files:** +- Create: `internal/infrastructure/email/console.go` +- Create: `internal/infrastructure/email/email.go` + +**Interfaces:** +- Produces: `ConsoleMailer` struct implementing `domain.Emailer`, `NewEmailer()` factory + +- [ ] **Step 1: Create console provider** + +```go +// internal/infrastructure/email/console.go +package email + +import ( + "fmt" + "log" +) + +type ConsoleMailer struct { + from string + fromName string +} + +func NewConsoleMailer(from, fromName string) *ConsoleMailer { + return &ConsoleMailer{from: from, fromName: fromName} +} + +func (m *ConsoleMailer) SendVerification(to, name, token string) error { + log.Printf("[EMAIL] To: %s | Subject: Verify your email | Link: %s/verify-email?token=%s", to, name, token) + return nil +} + +func (m *ConsoleMailer) SendPasswordReset(to, name, token string) error { + log.Printf("[EMAIL] To: %s | Subject: Reset your password | Link: %s/reset-password?token=%s", to, name, token) + return nil +} + +func (m *ConsoleMailer) SendWelcome(to, name string) error { + log.Printf("[EMAIL] To: %s | Subject: Welcome %s!", to, name) + return nil +} + +func (m *ConsoleMailer) SendInvite(to, name, inviterName string) error { + log.Printf("[EMAIL] To: %s | Subject: %s invited you to join", to, inviterName) + return nil +} +``` + +- [ ] **Step 2: Create factory function** + +```go +// internal/infrastructure/email/email.go +package email + +import ( + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/config" + "go.uber.org/fx" +) + +var Module = fx.Module("email", fx.Provide(NewEmailer)) + +func NewEmailer(cfg *config.Config) domain.Emailer { + switch cfg.Email.Provider { + case "smtp": + return NewSMTPMailer(cfg.Email.SMTP.Host, cfg.Email.SMTP.Port, + cfg.Email.SMTP.Username, cfg.Email.SMTP.Password, + cfg.Email.SMTP.UseTLS, cfg.Email.From, cfg.Email.FromName) + case "sendgrid": + return NewSendGridMailer(cfg.Email.SendGrid.APIKey, + cfg.Email.From, cfg.Email.FromName) + default: + return NewConsoleMailer(cfg.Email.From, cfg.Email.FromName) + } +} +``` + +- [ ] **Step 3: Verify build** + +Run: `go build ./...` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add internal/infrastructure/email/ +git commit -m "email: add console provider and factory" +``` + +--- + +### Task 3: SMTP Provider + +**Files:** +- Create: `internal/infrastructure/email/smtp.go` + +**Interfaces:** +- Produces: `SMTPMailer` struct implementing `domain.Emailer` + +- [ ] **Step 1: Create SMTP provider** + +```go +// internal/infrastructure/email/smtp.go +package email + +import ( + "crypto/tls" + "fmt" + "net/smtp" +) + +type SMTPMailer struct { + host string + port int + username string + password string + useTLS bool + from string + fromName string +} + +func NewSMTPMailer(host string, port int, username, password string, useTLS bool, from, fromName string) *SMTPMailer { + return &SMTPMailer{ + host: host, port: port, username: username, + password: password, useTLS: useTLS, from: from, fromName: fromName, + } +} + +func (m *SMTPMailer) SendVerification(to, name, token string) error { + subject := "Verify your email address" + body := fmt.Sprintf("Hello %s,\n\nPlease verify your email by clicking the link below:\n\n%s/verify-email?token=%s\n\nIf you didn't create an account, please ignore this email.", name, token, token) + return m.send(to, subject, body) +} + +func (m *SMTPMailer) SendPasswordReset(to, name, token string) error { + subject := "Reset your password" + body := fmt.Sprintf("Hello %s,\n\nYou requested a password reset. Click the link below to reset your password:\n\n%s/reset-password?token=%s\n\nThis link expires in 1 hour. If you didn't request this, please ignore this email.", name, token, token) + return m.send(to, subject, body) +} + +func (m *SMTPMailer) SendWelcome(to, name string) error { + subject := fmt.Sprintf("Welcome %s!", name) + body := fmt.Sprintf("Hello %s,\n\nWelcome to our platform! Your account is now active.", name) + return m.send(to, subject, body) +} + +func (m *SMTPMailer) SendInvite(to, name, inviterName string) error { + subject := fmt.Sprintf("%s invited you to join", inviterName) + body := fmt.Sprintf("Hello %s,\n\n%s has invited you to join our platform.\n\nClick the link below to get started.", name, inviterName) + return m.send(to, subject, body) +} + +func (m *SMTPMailer) send(to, subject, body string) error { + addr := fmt.Sprintf("%s:%d", m.host, m.port) + auth := smtp.PlainAuth("", m.username, m.password, m.host) + + msg := fmt.Sprintf("From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", + m.fromName, m.from, to, subject, body) + + if m.useTLS { + return m.sendWithTLS(addr, auth, to, msg) + } + return smtp.SendMail(addr, auth, m.from, []string{to}, []byte(msg)) +} + +func (m *SMTPMailer) sendWithTLS(addr string, auth smtp.Auth, to, msg string) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: m.host}) + if err != nil { + return err + } + client, err := smtp.NewClient(conn, m.host) + if err != nil { + return err + } + defer client.Close() + if err = client.Auth(auth); err != nil { + return err + } + if err = client.Mail(m.from); err != nil { + return err + } + if err = client.Rcpt(to); err != nil { + return err + } + w, err := client.Data() + if err != nil { + return err + } + if _, err = w.Write([]byte(msg)); err != nil { + return err + } + if err = w.Close(); err != nil { + return err + } + return client.Quit() +} +``` + +- [ ] **Step 2: Verify build** + +Run: `go build ./...` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add internal/infrastructure/email/smtp.go +git commit -m "email: add SMTP provider" +``` + +--- + +### Task 4: SendGrid Provider + +**Files:** +- Create: `internal/infrastructure/email/sendgrid.go` + +**Interfaces:** +- Produces: `SendGridMailer` struct implementing `domain.Emailer` + +- [ ] **Step 1: Add SendGrid dependency** + +Run: `go get github.com/sendgrid/sendgrid-go` +Run: `go mod tidy` + +- [ ] **Step 2: Create SendGrid provider** + +```go +// internal/infrastructure/email/sendgrid.go +package email + +import ( + "fmt" + + "github.com/sendgrid/sendgrid-go" + "github.com/sendgrid/sendgrid-go/helpers/mail" +) + +type SendGridMailer struct { + apiKey string + from string + fromName string +} + +func NewSendGridMailer(apiKey, from, fromName string) *SendGridMailer { + return &SendGridMailer{apiKey: apiKey, from: from, fromName: fromName} +} + +func (m *SendGridMailer) SendVerification(to, name, token string) error { + subject := "Verify your email address" + htmlContent := fmt.Sprintf(`

Hello %s,

Please verify your email by clicking the link below:

Verify Email

If you didn't create an account, please ignore this email.

`, name, token) + return m.send(to, subject, htmlContent) +} + +func (m *SendGridMailer) SendPasswordReset(to, name, token string) error { + subject := "Reset your password" + htmlContent := fmt.Sprintf(`

Hello %s,

You requested a password reset. Click the link below:

Reset Password

This link expires in 1 hour.

`, name, token) + return m.send(to, subject, htmlContent) +} + +func (m *SendGridMailer) SendWelcome(to, name string) error { + subject := fmt.Sprintf("Welcome %s!", name) + htmlContent := fmt.Sprintf(`

Hello %s,

Welcome to our platform! Your account is now active.

`, name) + return m.send(to, subject, htmlContent) +} + +func (m *SendGridMailer) SendInvite(to, name, inviterName string) error { + subject := fmt.Sprintf("%s invited you to join", inviterName) + htmlContent := fmt.Sprintf(`

Hello %s,

%s has invited you to join our platform.

`, name, inviterName) + return m.send(to, subject, htmlContent) +} + +func (m *SendGridMailer) send(to, subject, htmlContent string) error { + from := mail.NewEmail(m.fromName, m.from) + message := mail.NewSingleEmail(from, subject, mail.NewEmail(to, to), "", htmlContent) + client := sendgrid.NewSendClient(m.apiKey) + _, err := client.Send(message) + return err +} +``` + +- [ ] **Step 3: Verify build** + +Run: `go build ./...` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add internal/infrastructure/email/sendgrid.go go.mod go.sum +git commit -m "email: add SendGrid provider" +``` + +--- + +### Task 5: HTML Templates + +**Files:** +- Create: `internal/infrastructure/email/templates/verification.html` +- Create: `internal/infrastructure/email/templates/password_reset.html` +- Create: `internal/infrastructure/email/templates/welcome.html` +- Create: `internal/infrastructure/email/templates/invite.html` + +**Interfaces:** +- Produces: Embedded HTML templates + +- [ ] **Step 1: Create verification template** + +```html + + + + + +

Verify Your Email

+

Hello {{.Name}},

+

Thanks for signing up! Please verify your email address by clicking the button below:

+

+ Verify Email +

+

If you didn't create an account, please ignore this email.

+
+

This link will expire in 24 hours.

+ + +``` + +- [ ] **Step 2: Create password reset template** + +```html + + + + + +

Reset Your Password

+

Hello {{.Name}},

+

You requested a password reset. Click the button below to set a new password:

+

+ Reset Password +

+

If you didn't request this, please ignore this email.

+
+

This link will expire in 1 hour.

+ + +``` + +- [ ] **Step 3: Create welcome template** + +```html + + + + + +

Welcome!

+

Hello {{.Name}},

+

Welcome to our platform! Your account is now active and ready to use.

+

If you have any questions, feel free to reach out to our support team.

+
+

This is an automated message, please do not reply.

+ + +``` + +- [ ] **Step 4: Create invite template** + +```html + + + + + +

You've Been Invited

+

Hello {{.Name}},

+

{{.InviterName}} has invited you to join our platform.

+

+ Accept Invitation +

+
+

This is an automated message, please do not reply.

+ + +``` + +- [ ] **Step 5: Commit** + +```bash +git add internal/infrastructure/email/templates/ +git commit -m "email: add HTML templates for verification, reset, welcome, invite" +``` + +--- + +### Task 6: Template Rendering in Providers + +**Files:** +- Modify: `internal/infrastructure/email/console.go` +- Modify: `internal/infrastructure/email/smtp.go` +- Modify: `internal/infrastructure/email/sendgrid.go` + +**Interfaces:** +- Consumes: HTML templates from Task 5 + +- [ ] **Step 1: Create template renderer** + +Create `internal/infrastructure/email/renderer.go`: + +```go +package email + +import ( + "embed" + "html/template" + "bytes" +) + +//go:embed templates/*.html +var templateFS embed.FS + +type TemplateData struct { + Name string + VerifyURL string + ResetURL string + InviteURL string + InviterName string +} + +func renderTemplate(name string, data TemplateData) (string, error) { + tmplPath := "templates/" + name + ".html" + t, err := template.ParseFS(templateFS, tmplPath) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err := t.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} +``` + +- [ ] **Step 2: Update ConsoleMailer to use templates** + +Replace the console provider's methods to use `renderTemplate`: + +```go +func (m *ConsoleMailer) SendVerification(to, name, token string) error { + verifyURL := m.frontendURL + "/verify-email?token=" + token + content, err := renderTemplate("verification", TemplateData{Name: name, VerifyURL: verifyURL}) + if err != nil { + return err + } + log.Printf("[EMAIL] To: %s | Subject: Verify your email\n%s", to, content) + return nil +} + +func (m *ConsoleMailer) SendPasswordReset(to, name, token string) error { + resetURL := m.frontendURL + "/reset-password?token=" + token + content, err := renderTemplate("password_reset", TemplateData{Name: name, ResetURL: resetURL}) + if err != nil { + return err + } + log.Printf("[EMAIL] To: %s | Subject: Reset your password\n%s", to, content) + return nil +} + +func (m *ConsoleMailer) SendWelcome(to, name string) error { + content, err := renderTemplate("welcome", TemplateData{Name: name}) + if err != nil { + return err + } + log.Printf("[EMAIL] To: %s | Subject: Welcome %s!\n%s", to, name, content) + return nil +} + +func (m *ConsoleMailer) SendInvite(to, name, inviterName string) error { + content, err := renderTemplate("invite", TemplateData{Name: name, InviterName: inviterName}) + if err != nil { + return err + } + log.Printf("[EMAIL] To: %s | Subject: %s invited you\n%s", to, inviterName, content) + return nil +} +``` + +Update `ConsoleMailer` struct to include `frontendURL` field. Update `NewConsoleMailer` to accept it. + +- [ ] **Step 3: Update SMTPMailer to use HTML content type** + +Update the `send` method to use `text/html` instead of `text/plain`, and use `renderTemplate` in each method. + +- [ ] **Step 4: Update SendGridMailer to use templates** + +Update each method to use `renderTemplate` for the HTML content. + +- [ ] **Step 5: Update factory to pass frontendURL** + +```go +func NewEmailer(cfg *config.Config) domain.Emailer { + switch cfg.Email.Provider { + case "smtp": + return NewSMTPMailer(cfg.Email.SMTP.Host, cfg.Email.SMTP.Port, + cfg.Email.SMTP.Username, cfg.Email.SMTP.Password, + cfg.Email.SMTP.UseTLS, cfg.Email.From, cfg.Email.FromName, cfg.Email.FrontendURL) + case "sendgrid": + return NewSendGridMailer(cfg.Email.SendGrid.APIKey, + cfg.Email.From, cfg.Email.FromName, cfg.Email.FrontendURL) + default: + return NewConsoleMailer(cfg.Email.From, cfg.Email.FromName, cfg.Email.FrontendURL) + } +} +``` + +- [ ] **Step 6: Verify build** + +Run: `go build ./...` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add internal/infrastructure/email/ +git commit -m "email: add template rendering to all providers" +``` + +--- + +### Task 7: User Entity Migration + +**Files:** +- Create: `migrations/005_add_email_verification.sql` +- Modify: `internal/authentication/domain/entity/user.go` + +**Interfaces:** +- Produces: Extended User entity with verification/reset fields + +- [ ] **Step 1: Create migration** + +```sql +-- migrations/005_add_email_verification.sql +-- +goose Up +ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT false; +ALTER TABLE users ADD COLUMN email_verify_token VARCHAR(255); +ALTER TABLE users ADD COLUMN email_verify_expires TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN password_reset_token VARCHAR(255); +ALTER TABLE users ADD COLUMN password_reset_expires TIMESTAMPTZ; + +CREATE INDEX idx_users_email_verify_token ON users(email_verify_token) WHERE email_verify_token IS NOT NULL; +CREATE INDEX idx_users_password_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL; + +-- +goose Down +DROP INDEX IF EXISTS idx_users_email_verify_token; +DROP INDEX IF EXISTS idx_users_password_reset_token; +ALTER TABLE users DROP COLUMN IF EXISTS email_verified; +ALTER TABLE users DROP COLUMN IF EXISTS email_verify_token; +ALTER TABLE users DROP COLUMN IF EXISTS email_verify_expires; +ALTER TABLE users DROP COLUMN IF EXISTS password_reset_token; +ALTER TABLE users DROP COLUMN IF EXISTS password_reset_expires; +``` + +- [ ] **Step 2: Update User entity** + +Add fields to `internal/authentication/domain/entity/user.go`: + +```go +type User struct { + domain.Entity + Email string `json:"email"` + Password string `json:"-"` + Name string `json:"name"` + IsActive bool `json:"is_active"` + FailedLoginAttempts int `json:"failed_login_attempts"` + LockedUntil *time.Time `json:"locked_until,omitempty"` + EmailVerified bool `json:"email_verified"` + EmailVerifyToken *string `json:"-"` + EmailVerifyExpires *time.Time `json:"-"` + PasswordResetToken *string `json:"-"` + PasswordResetExpires *time.Time `json:"-"` +} +``` + +- [ ] **Step 3: Update UserRepository interface** + +Add to `internal/authentication/domain/repository/authentication_repository.go`: + +```go +type UserRepository interface { + Create(ctx context.Context, user *entity.User) error + GetByID(ctx context.Context, id uuid.UUID) (*entity.User, error) + GetByEmail(ctx context.Context, email string) (*entity.User, error) + GetByVerifyToken(ctx context.Context, token string) (*entity.User, error) + GetByResetToken(ctx context.Context, token string) (*entity.User, error) + Update(ctx context.Context, user *entity.User) error +} +``` + +- [ ] **Step 4: Update UserRepository implementation** + +Add `GetByVerifyToken` and `GetByResetToken` methods to `internal/authentication/infrastructure/persistence/user_repository.go`. + +- [ ] **Step 5: Verify build** + +Run: `go build ./...` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add migrations/005_add_email_verification.sql internal/authentication/domain/entity/user.go internal/authentication/domain/repository/authentication_repository.go internal/authentication/infrastructure/persistence/user_repository.go +git commit -m "auth: add email verification and password reset fields to user entity" +``` + +--- + +### Task 8: Auth Service Changes + +**Files:** +- Modify: `internal/authentication/application/service/authentication_service.go` +- Modify: `internal/authentication/application/dto/authentication_dto.go` + +**Interfaces:** +- Consumes: `domain.Emailer` from Task 1-6 +- Produces: Modified `Register`, `Login` methods; new `VerifyEmail`, `ForgotPassword`, `ResetPassword`, `ResendVerification` methods + +- [ ] **Step 1: Add new DTOs** + +Add to `internal/authentication/application/dto/authentication_dto.go`: + +```go +type ForgotPasswordRequest struct { + Email string `json:"email" validate:"required,email"` +} + +type ResetPasswordRequest struct { + Token string `json:"token" validate:"required"` + NewPassword string `json:"new_password" validate:"required,min=8"` +} + +type ResendVerificationRequest struct { + Email string `json:"email" validate:"required,email"` +} +``` + +- [ ] **Step 2: Update AuthenticationService struct** + +Add `emailer domain.Emailer` and `frontendURL string` fields: + +```go +type AuthenticationService struct { + userRepo repository.UserRepository + refreshRepo repository.RefreshTokenRepository + tokenService domain.TokenService + mailer domain.Emailer + frontendURL string +} +``` + +Update `NewAuthenticationService` to accept `emailer` and `frontendURL`. + +- [ ] **Step 3: Add token generation helpers** + +```go +func generateToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} +``` + +- [ ] **Step 4: Update Register method** + +After creating user, generate verification token, store it, send verification email: + +```go +func (s *AuthenticationService) Register(ctx context.Context, email, password, name string) (*entity.User, error) { + // ... existing validation ... + + user := entity.NewUser(email, hashedPassword, name) + if err := s.userRepo.Create(ctx, user); err != nil { + return nil, err + } + + // Generate verification token + token, err := generateToken() + if err != nil { + return nil, err + } + expires := time.Now().Add(24 * time.Hour) + user.EmailVerifyToken = &token + user.EmailVerifyExpires = &expires + if err := s.userRepo.Update(ctx, user); err != nil { + return nil, err + } + + // Send verification email + s.mailer.SendVerification(user.Email, user.Name, token) + + return user, nil +} +``` + +- [ ] **Step 5: Update Login method** + +Add email verification check: + +```go +func (s *AuthenticationService) Login(ctx context.Context, email, password string) (*entity.User, error) { + user, err := s.userRepo.GetByEmail(ctx, email) + if err != nil { + return nil, ErrInvalidCredentials + } + + if !user.EmailVerified { + return nil, ErrEmailNotVerified + } + + // ... existing login logic ... +} +``` + +Add `ErrEmailNotVerified` error. + +- [ ] **Step 6: Add VerifyEmail method** + +```go +func (s *AuthenticationService) VerifyEmail(ctx context.Context, token string) error { + user, err := s.userRepo.GetByVerifyToken(ctx, token) + if err != nil { + return fmt.Errorf("invalid or expired verification token") + } + if user.EmailVerifyExpires != nil && time.Now().After(*user.EmailVerifyExpires) { + return fmt.Errorf("verification token expired") + } + + user.EmailVerified = true + user.EmailVerifyToken = nil + user.EmailVerifyExpires = nil + if err := s.userRepo.Update(ctx, user); err != nil { + return err + } + + s.mailer.SendWelcome(user.Email, user.Name) + return nil +} +``` + +- [ ] **Step 7: Add ForgotPassword method** + +```go +func (s *AuthenticationService) ForgotPassword(ctx context.Context, email string) error { + user, err := s.userRepo.GetByEmail(ctx, email) + if err != nil { + return nil // Silently return to prevent enumeration + } + + token, err := generateToken() + if err != nil { + return err + } + expires := time.Now().Add(1 * time.Hour) + user.PasswordResetToken = &token + user.PasswordResetExpires = &expires + if err := s.userRepo.Update(ctx, user); err != nil { + return err + } + + s.mailer.SendPasswordReset(user.Email, user.Name, token) + return nil +} +``` + +- [ ] **Step 8: Add ResetPassword method** + +```go +func (s *AuthenticationService) ResetPassword(ctx context.Context, token, newPassword string) error { + user, err := s.userRepo.GetByResetToken(ctx, token) + if err != nil { + return fmt.Errorf("invalid or expired reset token") + } + if user.PasswordResetExpires != nil && time.Now().After(*user.PasswordResetExpires) { + return fmt.Errorf("reset token expired") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + + user.Password = string(hashedPassword) + user.PasswordResetToken = nil + user.PasswordResetExpires = nil + return s.userRepo.Update(ctx, user) +} +``` + +- [ ] **Step 9: Add ResendVerification method** + +```go +func (s *AuthenticationService) ResendVerification(ctx context.Context, email string) error { + user, err := s.userRepo.GetByEmail(ctx, email) + if err != nil { + return nil // Silently return + } + if user.EmailVerified { + return nil // Already verified + } + + token, err := generateToken() + if err != nil { + return err + } + expires := time.Now().Add(24 * time.Hour) + user.EmailVerifyToken = &token + user.EmailVerifyExpires = &expires + if err := s.userRepo.Update(ctx, user); err != nil { + return err + } + + return s.mailer.SendVerification(user.Email, user.Name, token) +} +``` + +- [ ] **Step 10: Verify build** + +Run: `go build ./...` +Expected: PASS + +- [ ] **Step 11: Commit** + +```bash +git add internal/authentication/application/ +git commit -m "auth: add email verification and password reset flows" +``` + +--- + +### Task 9: HTTP Handlers + Routes + +**Files:** +- Modify: `internal/authentication/interfaces/http/handlers.go` +- Modify: `internal/authentication/interfaces/http/routes.go` + +**Interfaces:** +- Consumes: Modified `AuthenticationService` from Task 8 + +- [ ] **Step 1: Add new handler methods** + +Add to `internal/authentication/interfaces/http/handlers.go`: + +```go +// VerifyEmail godoc +// @Summary Verify email address +// @Description Verify user email with token from email +// @Tags authentication +// @Param token query string true "Verification token" +// @Success 200 {object} utils.SuccessResponse +// @Failure 400 {object} utils.ErrorResponse +// @Router /auth/verify-email [get] +func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + utils.RespondBadRequest(w, "token is required") + return + } + if err := h.svc.VerifyEmail(r.Context(), token); err != nil { + utils.RespondBadRequest(w, err.Error()) + return + } + utils.RespondSuccess(w, map[string]string{"message": "email verified successfully"}) +} + +// ForgotPassword godoc +// @Summary Request password reset +// @Description Send password reset email +// @Tags authentication +// @Accept json +// @Produce json +// @Param request body dto.ForgotPasswordRequest true "Email address" +// @Success 200 {object} utils.SuccessResponse +// @Router /auth/forgot-password [post] +func (h *Handler) ForgotPassword(w http.ResponseWriter, r *http.Request) { + var req dto.ForgotPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondBadRequest(w, "invalid request body") + return + } + if err := h.svc.ForgotPassword(r.Context(), req.Email); err != nil { + utils.RespondInternalError(w, "failed to process request") + return + } + utils.RespondSuccess(w, map[string]string{"message": "if the email exists, a reset link has been sent"}) +} + +// ResetPassword godoc +// @Summary Reset password +// @Description Reset password with token from email +// @Tags authentication +// @Accept json +// @Produce json +// @Param request body dto.ResetPasswordRequest true "Token and new password" +// @Success 200 {object} utils.SuccessResponse +// @Failure 400 {object} utils.ErrorResponse +// @Router /auth/reset-password [post] +func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) { + var req dto.ResetPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondBadRequest(w, "invalid request body") + return + } + if err := h.validator.Validate(req); err != nil { + utils.RespondBadRequest(w, err.Error()) + return + } + if err := h.svc.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil { + utils.RespondBadRequest(w, err.Error()) + return + } + utils.RespondSuccess(w, map[string]string{"message": "password reset successfully"}) +} + +// ResendVerification godoc +// @Summary Resend verification email +// @Description Resend email verification link +// @Tags authentication +// @Accept json +// @Produce json +// @Param request body dto.ResendVerificationRequest true "Email address" +// @Success 200 {object} utils.SuccessResponse +// @Router /auth/resend-verification [post] +func (h *Handler) ResendVerification(w http.ResponseWriter, r *http.Request) { + var req dto.ResendVerificationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondBadRequest(w, "invalid request body") + return + } + if err := h.svc.ResendVerification(r.Context(), req.Email); err != nil { + utils.RespondInternalError(w, "failed to resend verification") + return + } + utils.RespondSuccess(w, map[string]string{"message": "if the email exists, a verification link has been sent"}) +} +``` + +- [ ] **Step 2: Add routes** + +Add to `internal/authentication/interfaces/http/routes.go`: + +```go +r.Get("/verify-email", handler.VerifyEmail) +r.Post("/forgot-password", handler.ForgotPassword) +r.Post("/reset-password", handler.ResetPassword) +r.Post("/resend-verification", handler.ResendVerification) +``` + +These are public routes (no auth middleware). + +- [ ] **Step 3: Verify build** + +Run: `go build ./...` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add internal/authentication/interfaces/http/ +git commit -m "auth: add verification and password reset HTTP endpoints" +``` + +--- + +### Task 10: Wire Email Module + +**Files:** +- Modify: `internal/authentication/module.go` +- Modify: `cmd/api/main.go` + +**Interfaces:** +- Consumes: `email.Module` from Task 2 + +- [ ] **Step 1: Update authentication module** + +Update `internal/authentication/module.go` to accept emailer: + +```go +var Module = fx.Module("authentication", + fx.Provide( + persistence.NewUserRepository, + persistence.NewRefreshTokenRepository, + service.NewAuthenticationService, + httpHandler.NewHandler, + ), +) +``` + +The `NewAuthenticationService` will now receive `domain.Emailer` via Fx injection. + +- [ ] **Step 2: Update main.go** + +Add email module to Fx: + +```go +import "github.com/IDTS-LAB/go-codebase/internal/infrastructure/email" + +// In fx.New(): +email.Module, +``` + +- [ ] **Step 3: Verify build** + +Run: `go build ./cmd/api` +Expected: PASS + +- [ ] **Step 4: Verify app starts** + +Run: `make run` (Ctrl+C after seeing "starting server") +Expected: Server starts without errors + +- [ ] **Step 5: Commit** + +```bash +git add internal/authentication/module.go cmd/api/main.go +git commit -m "auth: wire email module into Fx container" +``` + +--- + +### Task 11: Tests + +**Files:** +- Create: `internal/infrastructure/email/email_test.go` +- Create: `internal/authentication/application/service/authentication_service_test.go` + +**Interfaces:** +- Consumes: All previous tasks + +- [ ] **Step 1: Test console provider** + +```go +// internal/infrastructure/email/email_test.go +package email + +import ( + "testing" +) + +func TestConsoleMailer(t *testing.T) { + mailer := NewConsoleMailer("test@example.com", "Test App", "http://localhost:3000") + + if err := mailer.SendVerification("user@test.com", "Test User", "abc123"); err != nil { + t.Errorf("SendVerification failed: %v", err) + } + if err := mailer.SendPasswordReset("user@test.com", "Test User", "xyz789"); err != nil { + t.Errorf("SendPasswordReset failed: %v", err) + } + if err := mailer.SendWelcome("user@test.com", "Test User"); err != nil { + t.Errorf("SendWelcome failed: %v", err) + } + if err := mailer.SendInvite("user@test.com", "Test User", "Admin"); err != nil { + t.Errorf("SendInvite failed: %v", err) + } +} + +func TestNewEmailer(t *testing.T) { + // Test default (console) provider + cfg := &config.Config{} + cfg.Email.Provider = "console" + cfg.Email.From = "test@example.com" + cfg.Email.FromName = "Test" + cfg.Email.FrontendURL = "http://localhost:3000" + + mailer := NewEmailer(cfg) + if mailer == nil { + t.Error("NewEmailer returned nil") + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `go test ./internal/infrastructure/email/ -v` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add internal/infrastructure/email/email_test.go +git commit -m "email: add provider tests" +``` + +--- + +### Task 12: Swagger + Final Verification + +**Files:** +- Run: `make swagger` + +- [ ] **Step 1: Regenerate swagger docs** + +Run: `make swagger` +Expected: New endpoints appear in docs + +- [ ] **Step 2: Verify all endpoints in swagger** + +Run: `python3 -c "import json; d=json.load(open('docs/swagger.json')); [print(p) for p in sorted(d['paths'].keys()) if 'auth' in p]"` +Expected: Shows verify-email, forgot-password, reset-password, resend-verification + +- [ ] **Step 3: Full build and vet** + +Run: `go build ./... && go vet ./...` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add docs/ +git commit -m "docs: regenerate swagger with email endpoints" +``` diff --git a/docs/superpowers/plans/2026-07-10-event-driven-email.md b/docs/superpowers/plans/2026-07-10-event-driven-email.md new file mode 100644 index 0000000..a9338ed --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-event-driven-email.md @@ -0,0 +1,982 @@ +# Event-Driven Email, Unified Response, Global Error Handling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Decouple email from business logic via domain events, standardize all HTTP responses into a single envelope with pagination meta, and centralize error handling with consistent codes. + +**Architecture:** Services publish domain events to an `EventBus` interface (in-memory sync now, swappable for RabbitMQ/Kafka later). An `EmailHandler` subscribes and calls the mailer. A unified `APIResponse` envelope carries `data`, `meta` (for paginated lists), and `error` fields. Middleware uses shared response helpers instead of hardcoded JSON. `MapError` translates domain errors to HTTP codes. + +**Tech Stack:** Go 1.25, Fx DI, Chi router, PostgreSQL, `database/sql`, `lib/pq` + +## Global Constraints + +- Module path: `github.com/IDTS-LAB/go-codebase` +- Do NOT modify database schemas or migrations +- Do NOT modify domain entities +- Do NOT add new external dependencies +- Existing `Event` struct and `Handler` type in `internal/shared/events/` stay unchanged + +--- + +## File Structure + +| File | Status | Resp. | +|------|--------|-------| +| `internal/shared/events/events.go` | Modify | Extract EventBus interface, rename struct to InMemoryEventBus | +| `internal/shared/events/module.go` | Create | Fx module providing EventBus | +| `internal/shared/utils/utils.go` | Modify | APIResponse, PaginationMeta, MapError, new helpers | +| `internal/shared/middleware/middleware.go` | Modify | Use utils.Respond* instead of hardcoded JSON | +| `internal/shared/middleware/ratelimit.go` | Modify | Use utils.RespondError | +| `internal/authentication/domain/event/auth_events.go` | Create | UserRegistered, EmailVerified, PasswordResetRequested | +| `internal/authentication/infrastructure/eventbus/email_handler.go` | Create | Email event handler | +| `internal/authentication/application/service/authentication_service.go` | Modify | Remove mailer, publish events | +| `internal/authentication/application/service/authentication_service_test.go` | Modify | Remove mailer mock | +| `internal/authentication/interfaces/http/handlers.go` | Modify | MapError + standardized codes | +| `internal/authentication/interfaces/http/handlers_test.go` | Modify | New response structure | +| `internal/todo/application/command/create_todo_handler.go` | Modify | Publish todo.created | +| `internal/todo/application/command/update_todo_handler.go` | Modify | Publish todo.updated | +| `internal/todo/application/command/complete_todo_handler.go` | Modify | Publish todo.completed | +| `internal/todo/application/command/delete_todo_handler.go` | Modify | Publish todo.deleted | +| `internal/todo/interfaces/http/handlers.go` | Modify | MapError + RespondPaginated | +| `internal/todo/interfaces/http/handlers_test.go` | Modify | New response structure | +| `internal/todo/module.go` | Modify | Remove EventBus provide | +| `internal/authentication/module.go` | Modify | Remove mailer param from auth service | +| `internal/authorization/interfaces/http/handlers.go` | Modify | MapError + RespondPaginated | +| `internal/user/interfaces/http/handler.go` | Modify | MapError + RespondPaginated | +| `cmd/api/main.go` | Modify | Add email handler + shared EventBus | +| `docs/Architecture.md` | Modify | Add event-driven + response sections | +| `docs/FolderStructure.md` | Modify | Add new dirs | +| `docs/API.md` | Modify | Document envelope + error codes | + +--- + +### Task 1: EventBus Interface + Shared Module + +**Files:** +- Modify: `internal/shared/events/events.go` +- Create: `internal/shared/events/module.go` + +- [ ] **Step 1: Read existing events.go** + +```bash +cat internal/shared/events/events.go +``` + +- [ ] **Step 2: Rewrite `internal/shared/events/events.go`** + +Extract `EventBus` interface, add doc comments, rename current struct to `InMemoryEventBus`: + +```go +package events + +import ( + "context" + "sync" +) + +type Event struct { + Type string + Payload interface{} +} + +type Handler func(ctx context.Context, event Event) error + +type EventBus interface { + Publish(ctx context.Context, event Event) error + Subscribe(eventType string, handler Handler) +} + +type InMemoryEventBus struct { + mu sync.RWMutex + handlers map[string][]Handler +} + +func NewInMemoryEventBus() *InMemoryEventBus { + return &InMemoryEventBus{ + handlers: make(map[string][]Handler), + } +} + +func (eb *InMemoryEventBus) Subscribe(eventType string, handler Handler) { + eb.mu.Lock() + defer eb.mu.Unlock() + eb.handlers[eventType] = append(eb.handlers[eventType], handler) +} + +func (eb *InMemoryEventBus) Publish(ctx context.Context, event Event) error { + eb.mu.RLock() + handlers := eb.handlers[event.Type] + eb.mu.RUnlock() + + for _, h := range handlers { + if err := h(ctx, event); err != nil { + return err + } + } + return nil +} +``` + +- [ ] **Step 3: Create `internal/shared/events/module.go`** + +```go +package events + +import "go.uber.org/fx" + +var Module = fx.Module("events", + fx.Provide( + fx.Annotate(NewInMemoryEventBus, fx.As(new(EventBus))), + ), +) +``` + +- [ ] **Step 4: Verify build** + +```bash +go build ./... +``` + +Expected: Clean build. + +- [ ] **Step 5: Commit** + +```bash +git add internal/shared/events/ +git commit -m "feat(events): extract EventBus interface, rename InMemoryEventBus" +``` + +--- + +### Task 2: Auth Domain Events + Email Handler + +**Files:** +- Create: `internal/authentication/domain/event/auth_events.go` +- Create: `internal/authentication/infrastructure/eventbus/email_handler.go` + +- [ ] **Step 1: Create `internal/authentication/domain/event/auth_events.go`** + +```go +package event + +type UserRegistered struct { + Email string + Name string + VerificationToken string +} + +type EmailVerified struct { + UserID string + Email string + Name string +} + +type PasswordResetRequested struct { + Email string + Name string + ResetToken string +} + +const ( + UserRegisteredEvent = "auth.user.registered" + EmailVerifiedEvent = "auth.user.email_verified" + PasswordResetRequestedEvent = "auth.user.password_reset_requested" +) +``` + +- [ ] **Step 2: Create `internal/authentication/infrastructure/eventbus/email_handler.go`** + +```go +package eventbus + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/event" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" +) + +type EmailHandler struct { + mailer domain.Emailer + log domain.Logger +} + +func NewEmailHandler(mailer domain.Emailer, log domain.Logger) *EmailHandler { + return &EmailHandler{mailer: mailer, log: log} +} + +func (h *EmailHandler) Register(bus events.EventBus) { + bus.Subscribe(event.UserRegisteredEvent, h.onUserRegistered) + bus.Subscribe(event.EmailVerifiedEvent, h.onEmailVerified) + bus.Subscribe(event.PasswordResetRequestedEvent, h.onPasswordResetRequested) +} + +func (h *EmailHandler) onUserRegistered(ctx context.Context, e events.Event) error { + payload, ok := e.Payload.(event.UserRegistered) + if !ok { + return nil + } + if err := h.mailer.SendVerification(payload.Email, payload.Name, payload.VerificationToken); err != nil { + h.log.Error(ctx, "failed to send verification email", domain.Error(err)) + } + return nil +} + +func (h *EmailHandler) onEmailVerified(ctx context.Context, e events.Event) error { + payload, ok := e.Payload.(event.EmailVerified) + if !ok { + return nil + } + if err := h.mailer.SendWelcome(payload.Email, payload.Name); err != nil { + h.log.Error(ctx, "failed to send welcome email", domain.Error(err)) + } + return nil +} + +func (h *EmailHandler) onPasswordResetRequested(ctx context.Context, e events.Event) error { + payload, ok := e.Payload.(event.PasswordResetRequested) + if !ok { + return nil + } + if err := h.mailer.SendPasswordReset(payload.Email, payload.Name, payload.ResetToken); err != nil { + h.log.Error(ctx, "failed to send password reset email", domain.Error(err)) + } + return nil +} +``` + +- [ ] **Step 3: Verify build** + +```bash +go build ./... +``` + +Expected: Clean build. + +- [ ] **Step 4: Commit** + +```bash +git add internal/authentication/domain/event/ internal/authentication/infrastructure/eventbus/ +git commit -m "feat(auth): add domain events and email event handler" +``` + +--- + +### Task 3: Auth Service — Publish Events, Remove Mailer + +**Files:** +- Modify: `internal/authentication/application/service/authentication_service.go` +- Modify: `internal/authentication/application/service/authentication_service_test.go` + +- [ ] **Step 1: Read current `authentication_service.go`** + +```bash +cat internal/authentication/application/service/authentication_service.go +``` + +- [ ] **Step 2: Modify constructor** + +Replace `mailer domain.Emailer` param with `bus events.EventBus`. Add imports for `events` and `authentication/domain/event`. + +```go +type AuthenticationService struct { + userRepo repository.UserRepository + tokenRepo repository.RefreshTokenRepository + passwordSvc password.Service + tokenSvc token.Service + bus events.EventBus + accessTokenExpiry time.Duration + refreshTokenExpiry time.Duration + maxFailedAttempts int + lockoutDuration time.Duration +} + +func NewAuthenticationService( + userRepo repository.UserRepository, + tokenRepo repository.RefreshTokenRepository, + passwordSvc password.Service, + tokenSvc token.Service, + bus events.EventBus, + cfg *config.Config, +) *AuthenticationService { + return &AuthenticationService{ + userRepo: userRepo, + tokenRepo: tokenRepo, + passwordSvc: passwordSvc, + tokenSvc: tokenSvc, + bus: bus, + accessTokenExpiry: cfg.JWT.AccessTokenExpiry, + refreshTokenExpiry: cfg.JWT.RefreshTokenExpiry, + maxFailedAttempts: cfg.Auth.MaxFailedAttempts, + lockoutDuration: cfg.Auth.LockoutDuration, + } +} +``` + +- [ ] **Step 3: Modify `Register` method — publish `UserRegistered` instead of calling mailer** + +In the `Register` method, replace `s.mailer.SendVerification(...)` with: + +```go +_ = s.bus.Publish(ctx, events.Event{ + Type: event.UserRegisteredEvent, + Payload: event.UserRegistered{ + Email: user.Email, + Name: user.Name, + VerificationToken: token, + }, +}) +``` + +Remove the `_ = s.mailer.SendVerification(...)` line entirely. Remove `mailer.SendWelcome` from `VerifyEmail`, `mailer.SendPasswordReset` from `ForgotPassword`, and `mailer.SendVerification` from `ResendVerification`. + +- [ ] **Step 4: Modify `VerifyEmail` — publish `EmailVerified`** + +```go +_ = s.bus.Publish(ctx, events.Event{ + Type: event.EmailVerifiedEvent, + Payload: event.EmailVerified{ + UserID: user.ID.String(), + Email: user.Email, + Name: user.Name, + }, +}) +``` + +- [ ] **Step 5: Modify `ForgotPassword` — publish `PasswordResetRequested`** + +```go +_ = s.bus.Publish(ctx, events.Event{ + Type: event.PasswordResetRequestedEvent, + Payload: event.PasswordResetRequested{ + Email: user.Email, + Name: user.Name, + ResetToken: token, + }, +}) +``` + +- [ ] **Step 6: Modify `ResendVerification` — publish `UserRegistered` (same as Register)** + +```go +return s.bus.Publish(ctx, events.Event{ + Type: event.UserRegisteredEvent, + Payload: event.UserRegistered{ + Email: user.Email, + Name: user.Name, + VerificationToken: token, + }, +}) +``` + +- [ ] **Step 7: Remove unused imports** (`domain.Emailer` not used anymore) and remove the `mailer` field reference. + +- [ ] **Step 8: Update `authentication_service_test.go`** + +Read the current test file. The tests create `AuthenticationService` with a mailer mock. Replace the mailer mock with a nil bus or a simple in-memory bus. Remove the mailer assertions (we test email sending separately via the handler). + +Replace test setup: + +```go +bus := events.NewInMemoryEventBus() +svc := service.NewAuthenticationService(userRepo, tokenRepo, passwordSvc, tokenSvc, bus, cfg) +``` + +Remove any `mockMailer` references and `mailer.AssertExpectations` calls. + +- [ ] **Step 9: Verify build + tests** + +```bash +go build ./... +go test ./internal/authentication/... 2>&1 +``` + +Expected: Build and tests pass. + +- [ ] **Step 10: Commit** + +```bash +git add internal/authentication/application/service/ +git commit -m "feat(auth): replace direct mailer calls with event publishing" +``` + +--- + +### Task 4: Todo Event Publishing + +**Files:** +- Modify: `internal/todo/application/command/create_todo_handler.go` +- Modify: `internal/todo/application/command/update_todo_handler.go` +- Modify: `internal/todo/application/command/complete_todo_handler.go` +- Modify: `internal/todo/application/command/delete_todo_handler.go` + +- [ ] **Step 1: Modify `create_todo_handler.go`** + +Add `events.EventBus` to the constructor. In the `Handle` method, publish `todo.created` after successful creation: + +```go +type CreateTodoHandler struct { + todoRepo repository.TodoRepository + eventBus events.EventBus +} + +func NewCreateTodoHandler(todoRepo repository.TodoRepository, eventBus events.EventBus) *CreateTodoHandler { + return &CreateTodoHandler{todoRepo: todoRepo, eventBus: eventBus} +} + +func (h *CreateTodoHandler) Handle(ctx context.Context, cmd command.CreateTodoCommand) (*entity.Todo, error) { + // ... existing validation and creation code ... + + if err := h.todoRepo.Create(ctx, todo); err != nil { + return nil, err + } + + _ = h.eventBus.Publish(ctx, events.Event{ + Type: event.TodoCreatedEvent, + Payload: event.TodoCreated{ + ID: todo.ID, + Title: todo.Title, + CreatedAt: todo.CreatedAt, + }, + }) + + return todo, nil +} +``` + +- [ ] **Step 2: Modify `update_todo_handler.go`** + +Similar pattern — inject `events.EventBus`, publish `todo.updated` after update. + +- [ ] **Step 3: Modify `complete_todo_handler.go`** + +Inject `events.EventBus`, publish `todo.completed` after completion. + +- [ ] **Step 4: Modify `delete_todo_handler.go`** + +Inject `events.EventBus`, publish `todo.deleted` after deletion. + +- [ ] **Step 5: Verify build** + +```bash +go build ./... +``` + +Expected: Clean build. + +- [ ] **Step 6: Commit** + +```bash +git add internal/todo/application/command/ +git commit -m "feat(todo): wire event publishing in command handlers" +``` + +--- + +### Task 5: Unified Response Envelope + Global Error Middleware + +**Files:** +- Modify: `internal/shared/utils/utils.go` +- Modify: `internal/shared/middleware/middleware.go` +- Modify: `internal/shared/middleware/ratelimit.go` + +- [ ] **Step 1: Rewrite `internal/shared/utils/utils.go`** + +```go +package utils + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/IDTS-LAB/go-codebase/internal/core/domain" +) + +type APIResponse struct { + Success bool `json:"success"` + Data interface{} `json:"data"` + Meta *PaginationMeta `json:"meta"` + Error *ErrorBody `json:"error,omitempty"` +} + +type PaginationMeta struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` +} + +type ErrorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func RespondJSON(w http.ResponseWriter, status int, payload interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(payload) +} + +func RespondSuccess(w http.ResponseWriter, data interface{}) { + RespondJSON(w, http.StatusOK, APIResponse{Success: true, Data: data}) +} + +func RespondCreated(w http.ResponseWriter, data interface{}) { + RespondJSON(w, http.StatusCreated, APIResponse{Success: true, Data: data}) +} + +func RespondPaginated(w http.ResponseWriter, data interface{}, page, perPage, total int) { + totalPages := (total + perPage - 1) / perPage + if totalPages < 0 { + totalPages = 0 + } + RespondJSON(w, http.StatusOK, APIResponse{ + Success: true, + Data: data, + Meta: &PaginationMeta{ + Page: page, + PerPage: perPage, + Total: total, + TotalPages: totalPages, + }, + }) +} + +func RespondError(w http.ResponseWriter, status int, code, message string) { + RespondJSON(w, status, APIResponse{ + Success: false, + Error: &ErrorBody{Code: code, Message: message}, + }) +} + +func RespondBadRequest(w http.ResponseWriter, message string) { + RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", message) +} + +func RespondUnauthorized(w http.ResponseWriter, message string) { + RespondError(w, http.StatusUnauthorized, "UNAUTHORIZED", message) +} + +func RespondForbidden(w http.ResponseWriter, code, message string) { + RespondError(w, http.StatusForbidden, code, message) +} + +func RespondNotFound(w http.ResponseWriter, message string) { + RespondError(w, http.StatusNotFound, "NOT_FOUND", message) +} + +func RespondConflict(w http.ResponseWriter, message string) { + RespondError(w, http.StatusConflict, "CONFLICT", message) +} + +func RespondInternalError(w http.ResponseWriter, message string) { + RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", message) +} + +func MapError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, domain.ErrNotFound): + RespondNotFound(w, err.Error()) + case errors.Is(err, domain.ErrAlreadyExists) || errors.Is(err, domain.ErrConflict): + RespondConflict(w, err.Error()) + case errors.Is(err, domain.ErrValidation): + RespondBadRequest(w, err.Error()) + case errors.Is(err, domain.ErrForbidden): + RespondForbidden(w, "FORBIDDEN", err.Error()) + case errors.Is(err, domain.ErrUnauthorized): + RespondUnauthorized(w, err.Error()) + default: + RespondInternalError(w, "internal server error") + } +} +``` + +- [ ] **Step 2: Update `internal/shared/middleware/middleware.go`** + +Replace all hardcoded JSON strings with `utils.Respond*` calls. + +Replace the `ErrorHandler` panic recovery (line ~43): + +```go +func ErrorHandler(log domain.Logger) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.Error(r.Context(), "panic recovered", domain.String("panic", fmt.Sprintf("%v", rec))) + utils.RespondInternalError(w, "internal server error") + } + }() + next.ServeHTTP(w, r) + }) + } +} +``` + +Replace all `http.Error(w, hardcodedJSON, status)` in the `Authentication` and `Authorization` middleware with: + +```go +utils.RespondUnauthorized(w, "missing token") +// or +utils.RespondUnauthorized(w, "invalid token") +// or +utils.RespondForbidden(w, "FORBIDDEN", "insufficient permissions") +``` + +The specific replacements (exact error messages may vary slightly): + +| Middleware | Old hardcoded JSON | New call | +|---|---|---| +| Authentication (missing token) | `{"success":false,"error":{"code":"UNAUTHORIZED","message":"missing token"}}` | `utils.RespondUnauthorized(w, "missing token")` | +| Authentication (invalid token) | `{"success":false,"error":{"code":"UNAUTHORIZED","message":"invalid token"}}` | `utils.RespondUnauthorized(w, "invalid token")` | +| AuthWithDenylist (missing token) | same as above | `utils.RespondUnauthorized(w, "missing token")` | +| AuthWithDenylist (invalid token) | same as above | `utils.RespondUnauthorized(w, "invalid token")` | +| AuthWithDenylist (revoked) | `{"success":false,"error":{"code":"UNAUTHORIZED","message":"token has been revoked"}}` | `utils.RespondUnauthorized(w, "token has been revoked")` | +| Authorization (not authenticated) | `{"success":false,"error":{"code":"UNAUTHORIZED","message":"user not authenticated"}}` | `utils.RespondUnauthorized(w, "user not authenticated")` | +| Authorization (invalid user ID) | `{"success":false,"error":{"code":"UNAUTHORIZED","message":"invalid user ID"}}` | `utils.RespondUnauthorized(w, "invalid user ID")` | +| Authorization (check failed) | `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"authorization check failed"}}` | `utils.RespondInternalError(w, "authorization check failed")` | +| Authorization (insufficient perms) | `{"success":false,"error":{"code":"FORBIDDEN","message":"insufficient permissions"}}` | `utils.RespondForbidden(w, "FORBIDDEN", "insufficient permissions")` | + +Add `utils` import: `"github.com/IDTS-LAB/go-codebase/internal/shared/utils"`. + +- [ ] **Step 3: Update `internal/shared/middleware/ratelimit.go`** + +Replace the hardcoded JSON: + +```go +utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests") +``` + +Add the `utils` import. + +- [ ] **Step 4: Verify build** + +```bash +go build ./... +``` + +Expected: Clean build. + +- [ ] **Step 5: Commit** + +```bash +git add internal/shared/utils/ internal/shared/middleware/ +git commit -m "feat(http): unified response envelope, MapError, middleware cleanup" +``` + +--- + +### Task 6: Handler Updates — All 4 Domains + +**Files:** +- Modify: `internal/authentication/interfaces/http/handlers.go` +- Modify: `internal/authentication/interfaces/http/handlers_test.go` +- Modify: `internal/todo/interfaces/http/handlers.go` +- Modify: `internal/todo/interfaces/http/handlers_test.go` +- Modify: `internal/authorization/interfaces/http/handlers.go` +- Modify: `internal/user/interfaces/http/handler.go` + +- [ ] **Step 1: Update `authentication/interfaces/http/handlers.go`** + +Replace the existing per-handler `switch err` patterns with `utils.MapError` for common errors. Keep domain-specific error handling (like `ACCOUNT_LOCKED`, `EMAIL_NOT_VERIFIED`) as explicit checks before falling through to `MapError`. + +Pattern: + +```go +func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { + // ... decode, validate ... + user, err := h.authService.Register(r.Context(), req.Email, req.Password, req.Name) + if err != nil { + if errors.Is(err, service.ErrEmailAlreadyExists) { + utils.RespondConflict(w, err.Error()) + return + } + utils.RespondInternalError(w, "internal server error") + return + } + utils.RespondCreated(w, toUserResponse(user)) +} +``` + +Replace all handlers similarly. Key changes: +- `Login`: check `ErrInvalidCredentials` → `RespondUnauthorized`, `ErrAccountDisabled` → `RespondUnauthorized`, `ErrAccountLocked` → `RespondForbidden("ACCOUNT_LOCKED", ...)`, `ErrEmailNotVerified` → `RespondForbidden("EMAIL_NOT_VERIFIED", ...)`, else `RespondInternalError` +- `RefreshToken`: `ErrInvalidRefreshToken` → `RespondUnauthorized`, else `RespondInternalError` +- `VerifyEmail`: `ErrInvalidVerifyToken` / `ErrVerifyTokenExpired` → `RespondBadRequest`, else `RespondInternalError` +- `ForgotPassword`: always `RespondSuccess` (vague on purpose) +- `ResetPassword`: `ErrInvalidResetToken` / `ErrResetTokenExpired` → `RespondBadRequest`, else `RespondInternalError` +- `ResendVerification`: always `RespondSuccess` (vague on purpose) + +- [ ] **Step 2: Update `authentication/interfaces/http/handlers_test.go`** + +Read the current test file. The tests assert on response JSON bodies using struct fields. Update all test assertions to match the new `APIResponse` envelope structure. Key changes: +- Old: `{"success": true, "data": {...}}` +- New: `{"success": true, "data": {...}, "meta": null}` +- Old: `{"success": false, "error": {"code": "...", "message": "..."}}` +- New: `{"success": false, "data": null, "error": {"code": "...", "message": "..."}}` + +- [ ] **Step 3: Update `todo/interfaces/http/handlers.go`** + +Replace `switch err` with `MapError` pattern. For `ListTodos` and `SearchTodos`, use `RespondPaginated`: + +```go +utils.RespondPaginated(w, resp.Todos, req.Page, req.PerPage, resp.Total) +``` + +- [ ] **Step 4: Update `todo/interfaces/http/handlers_test.go`** + +Update response JSON assertions to match new envelope. Paginated responses now include `meta`. + +- [ ] **Step 5: Update `authorization/interfaces/http/handlers.go`** + +Replace blanket error handling with proper sentinel errors + `MapError`. For `CreateRole`, `CreatePermission` — use `MapError`. For `ListRoles`, `ListPermissions` — use `RespondPaginated`. + +- [ ] **Step 6: Update `user/interfaces/http/handler.go`** + +Replace error handling with `MapError`. Use `RespondPaginated` for `List`. + +- [ ] **Step 7: Verify build + tests** + +```bash +go build ./... +go test ./... 2>&1 +``` + +Expected: All tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add internal/authentication/interfaces/http/ internal/todo/interfaces/http/ internal/authorization/interfaces/http/ internal/user/interfaces/http/ +git commit -m "feat(handlers): use MapError, standardized codes, RespondPaginated for lists" +``` + +--- + +### Task 7: Fx Wiring + +**Files:** +- Modify: `internal/todo/module.go` +- Modify: `internal/authentication/module.go` +- Modify: `cmd/api/main.go` + +- [ ] **Step 1: Update `internal/todo/module.go`** + +Remove `events.NewEventBus` and `eventbus.NewTodoEventHandler` provides (they move to shared module or main). Remove the `fx.Invoke` that registers the todo event handler (it's still needed but depends on the shared EventBus). Actually keep the TodoEventHandler but remove the EventBus provide. + +```go +package todo + +import ( + "github.com/IDTS-LAB/go-codebase/internal/shared/events" + "github.com/IDTS-LAB/go-codebase/internal/todo/infrastructure/eventbus" + "go.uber.org/fx" +) + +var Module = fx.Module("todo", + fx.Provide( + // ... persistence, services, handlers ... + eventbus.NewTodoEventHandler, + ), + fx.Invoke( + func(bus events.EventBus, eh *eventbus.TodoEventHandler) { + eh.Register(bus) + }, + ), +) +``` + +Remove `events.NewEventBus` from the `fx.Provide` list. The EventBus is now provided by the shared events module. + +- [ ] **Step 2: Update `internal/authentication/module.go`** + +Remove `domain.Emailer` from the auth service constructor params. The auth service no longer depends on the mailer. + +Read the current module file and change the `fx.Provide` line from: + +```go +fx.Annotate(service.NewAuthenticationService, fx.ParamTags(...)), +``` + +to match the new constructor signature (replacing mailer param with EventBus). If there were no `fx.ParamTags`, the change is simply that the constructor signature changed and Fx resolves EventBus from the shared events module. + +- [ ] **Step 3: Update `cmd/api/main.go`** + +Add the shared events module and email handler registration. + +```go +import ( + "github.com/IDTS-LAB/go-codebase/internal/shared/events" + authEventBus "github.com/IDTS-LAB/go-codebase/internal/authentication/infrastructure/eventbus" +) + +app := fx.New( + fx.Supply(cfg), + + // Shared infrastructure + events.Module, // <-- provides EventBus interface + logger.Module, + // ... other modules ... + + // Applications modules + authentication.Module, + authorization.Module, + todo.Module, + user.Module, + + // Email handler registration + fx.Invoke(func(bus events.EventBus, eh *authEventBus.EmailHandler) { + eh.Register(bus) + }), + + // ... rest of main.go ... +) +``` + +- [ ] **Step 4: Verify build** + +```bash +go build ./... +``` + +Expected: Clean build. + +- [ ] **Step 5: Run tests** + +```bash +go test ./... 2>&1 +``` + +Expected: All tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add internal/todo/module.go internal/authentication/module.go cmd/api/main.go +git commit -m "chore: update Fx wiring for shared EventBus and email handler" +``` + +--- + +### Task 8: Documentation + +**Files:** +- Modify: `docs/Architecture.md` +- Modify: `docs/FolderStructure.md` +- Modify: `docs/API.md` + +- [ ] **Step 1: Update `docs/Architecture.md`** + +Add a section for event-driven email: + +```markdown +### Event-Driven Email + +Services publish domain events to an `EventBus` interface (in-memory synchronous by default, swappable for RabbitMQ/Kafka). An `EmailHandler` subscribes to auth domain events (`auth.user.registered`, `auth.user.email_verified`, `auth.user.password_reset_requested`) and calls the appropriate mailer method. + +Flow: `Service → EventBus.Publish() → EmailHandler → domain.Emailer.Send*()` +``` + +Add a section for unified response envelope: + +```markdown +### API Response Format + +All HTTP responses use a unified envelope: + +```json +// Success (single) +{"success": true, "data": {...}, "meta": null} + +// Success (paginated list) +{"success": true, "data": [...], "meta": {"page": 1, "per_page": 20, "total": 100, "total_pages": 5}} + +// Error +{"success": false, "data": null, "error": {"code": "VALIDATION_ERROR", "message": "..."}} +``` + +Errors are mapped to HTTP status codes via `utils.MapError`, which translates `domain.ErrNotFound`, `domain.ErrConflict`, etc. +``` +``` + +- [ ] **Step 2: Update `docs/FolderStructure.md`** + +Add entries: + +``` +internal/authentication/domain/event/ # Domain events (UserRegistered, EmailVerified, PasswordResetRequested) +internal/authentication/infrastructure/eventbus/ # Event handler implementations (EmailHandler) +internal/shared/events/ # EventBus interface + InMemoryEventBus + Fx module +``` + +- [ ] **Step 3: Update `docs/API.md`** + +Add a section at the top: + +```markdown +## Response Format + +All API responses follow this structure: + +| Field | Type | Description | +|-------|------|-------------| +| `success` | bool | Always present. `true` for success, `false` for error. | +| `data` | any | Response payload. `null` on errors. | +| `meta` | object or null | Pagination metadata. `null` for single-resource responses. | +| `error` | object or omitted | Error details. Present only on errors. | + +### Pagination + +Paginated list endpoints return `meta`: + +| Field | Type | Description | +|-------|------|-------------| +| `page` | int | Current page number | +| `per_page` | int | Items per page | +| `total` | int | Total items across all pages | +| `total_pages` | int | Total number of pages | + +### Error Codes + +| HTTP | Code | Description | +|------|------|-------------| +| 400 | `VALIDATION_ERROR` | Invalid request body or parameters | +| 401 | `UNAUTHORIZED` | Missing, invalid, or expired token | +| 403 | `FORBIDDEN` | Insufficient permissions | +| 403 | `ACCOUNT_LOCKED` | Account temporarily locked | +| 403 | `EMAIL_NOT_VERIFIED` | Email not verified | +| 404 | `NOT_FOUND` | Resource not found | +| 409 | `CONFLICT` | Duplicate or state conflict | +| 429 | `RATE_LIMITED` | Rate limit exceeded | +| 500 | `INTERNAL_ERROR` | Unexpected server error | +``` + +- [ ] **Step 4: Commit** + +```bash +git add docs/ +git commit -m "docs: update architecture, folder structure, and API docs for event-driven email, unified response, error handling" +``` + +--- + +## Verification Summary + +| Check | Expected | +|-------|----------| +| `go build ./...` | No compilation errors | +| `go test ./...` | All tests pass | +| `go vet ./...` | No vet issues | +| HTTP 200 | `{"success": true, "data": {...}, "meta": null}` | +| HTTP 201 | `{"success": true, "data": {...}, "meta": null}` | +| HTTP 400 | `{"success": false, "data": null, "error": {"code": "VALIDATION_ERROR", "message": "..."}}` | +| HTTP 401 | `{"success": false, "data": null, "error": {"code": "UNAUTHORIZED", "message": "..."}}` | +| HTTP 403 | `{"success": false, "data": null, "error": {"code": "FORBIDDEN", "message": "..."}}` | +| HTTP 404 | `{"success": false, "data": null, "error": {"code": "NOT_FOUND", "message": "..."}}` | +| HTTP 429 | `{"success": false, "data": null, "error": {"code": "RATE_LIMITED", "message": "..."}}` | +| Paginated list | `meta` includes `page`, `per_page`, `total`, `total_pages` | +| Email on register | `auth.user.registered` event → `EmailHandler` → `SendVerification()` | +| Email on verify | `auth.user.email_verified` event → `EmailHandler` → `SendWelcome()` | +| Email on forgot password | `auth.user.password_reset_requested` → `EmailHandler` → `SendPasswordReset()` | +| Todo events published | `todo.created`, `todo.updated`, `todo.completed`, `todo.deleted` emitted on actions | diff --git a/docs/superpowers/plans/2026-07-10-sqlc-repository-migration.md b/docs/superpowers/plans/2026-07-10-sqlc-repository-migration.md new file mode 100644 index 0000000..f2adf62 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-sqlc-repository-migration.md @@ -0,0 +1,1678 @@ +# SQLc Repository Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate all 9 hand-written `database/sql` repository implementations (42 methods) across 5 domains to sqlc-generated code while preserving all domain interfaces, entities, Fx wiring, and clean architecture. + +**Architecture:** One sqlc `sql` block per domain queries file, each outputting to its own `sqlc/` generated package. Repositories wrap `sqlc.New(r.db)` per method with mapping helpers to convert generated structs to domain entities. Generated code is gitignored and regenerated via `make sqlc`. + +**Tech Stack:** Go 1.25, sqlc v2, PostgreSQL 16 (via `lib/pq`), `database/sql` driver, `emit_pointers_for_null_types: true`, `emit_json_tags: true`, `emit_empty_slices: true` + +## Global Constraints + +- Module path: `github.com/IDTS-LAB/go-codebase` +- Do NOT modify any domain entity files (`internal/*/domain/entity/*.go`) +- Do NOT modify any domain repository interface files (`internal/*/domain/repository/*.go`) +- Do NOT modify any Fx module files (`internal/*/module.go`, `cmd/api/main.go`) +- Do NOT modify migration files (`migrations/*.sql`) +- Do NOT add `github.com/sqlc-dev/pqtype` dependency — use `::jsonb` casts for JSONB columns +- All generated `sqlc/` directories are gitignored +- Every task ends with `go build ./...` and a commit +- SQL queries use `-- name: PascalCase :one/:many/:exec` annotations exactly matching method semantics + +--- + +## File Structure + +| File | Status | Responsibility | +|------|--------|---------------| +| `sqlc.yaml` | **Modify** | Multi-domain sqlc config, one `sql` block per queries file | +| `.gitignore` | **Modify** | Add `**/infrastructure/persistence/sqlc/` and `internal/shared/auditlog/sqlc/` | +| `internal/todo/infrastructure/persistence/queries.sql` | **Delete** | Replaced by `queries/todo.sql` | +| `internal/todo/infrastructure/persistence/queries/todo.sql` | **Create** | Todo-domain sqlc queries | +| `internal/todo/infrastructure/persistence/todo_repository.go` | **Modify** | Refactor to wrap sqlc | +| `internal/authentication/infrastructure/persistence/queries/queries.sql` | **Create** | Auth-domain sqlc queries (user + refresh_token) | +| `internal/authentication/infrastructure/persistence/user_repository.go` | **Modify** | Refactor to wrap sqlc | +| `internal/authentication/infrastructure/persistence/refresh_token_repository.go` | **Modify** | Refactor to wrap sqlc | +| `internal/authorization/infrastructure/persistence/queries/queries.sql` | **Create** | Authorization-domain sqlc queries (role, permission, role_permission, user_role) | +| `internal/authorization/infrastructure/persistence/role_repository.go` | **Modify** | Refactor to wrap sqlc | +| `internal/authorization/infrastructure/persistence/permission_repository.go` | **Modify** | Refactor to wrap sqlc | +| `internal/authorization/infrastructure/persistence/role_permission_repository.go` | **Modify** | Refactor to wrap sqlc | +| `internal/authorization/infrastructure/persistence/user_role_repository.go` | **Modify** | Refactor to wrap sqlc | +| `internal/user/infrastructure/persistence/queries/queries.sql` | **Create** | User-domain sqlc queries | +| `internal/user/infrastructure/persistence/user_repository.go` | **Modify** | Refactor to wrap sqlc | +| `internal/shared/auditlog/queries/queries.sql` | **Create** | Auditlog sqlc queries (JSONB handling via `::jsonb`) | +| `internal/shared/auditlog/repository.go` | **Modify** | Refactor to wrap sqlc | + +**Generated (gitignored):** + +| Directory | Contents | +|-----------|----------| +| `internal/todo/infrastructure/persistence/sqlc/` | Generated from `queries/todo.sql` | +| `internal/authentication/infrastructure/persistence/sqlc/` | Generated from `queries/queries.sql` | +| `internal/authorization/infrastructure/persistence/sqlc/` | Generated from `queries/queries.sql` | +| `internal/user/infrastructure/persistence/sqlc/` | Generated from `queries/queries.sql` | +| `internal/shared/auditlog/sqlc/` | Generated from `queries/queries.sql` | + +--- + +### Task 1: Foundation — sqlc.yaml rewrite, .gitignore, make sqlc + +**Files:** +- Modify: `sqlc.yaml` — rewrite with all 5 domain sql blocks +- Modify: `.gitignore` — add sqlc/ directories + +- [ ] **Step 1: Rewrite `sqlc.yaml`** + +```yaml +version: "2" +sql: + - engine: "postgresql" + queries: "internal/todo/infrastructure/persistence/queries/todo.sql" + schema: "migrations" + gen: + go: + package: "sqlc" + out: "internal/todo/infrastructure/persistence/sqlc" + sql_package: "database/sql" + emit_json_tags: true + emit_empty_slices: true + emit_pointers_for_null_types: true + + - engine: "postgresql" + queries: "internal/authentication/infrastructure/persistence/queries/queries.sql" + schema: "migrations" + gen: + go: + package: "sqlc" + out: "internal/authentication/infrastructure/persistence/sqlc" + sql_package: "database/sql" + emit_json_tags: true + emit_empty_slices: true + emit_pointers_for_null_types: true + + - engine: "postgresql" + queries: "internal/authorization/infrastructure/persistence/queries/queries.sql" + schema: "migrations" + gen: + go: + package: "sqlc" + out: "internal/authorization/infrastructure/persistence/sqlc" + sql_package: "database/sql" + emit_json_tags: true + emit_empty_slices: true + emit_pointers_for_null_types: true + + - engine: "postgresql" + queries: "internal/user/infrastructure/persistence/queries/queries.sql" + schema: "migrations" + gen: + go: + package: "sqlc" + out: "internal/user/infrastructure/persistence/sqlc" + sql_package: "database/sql" + emit_json_tags: true + emit_empty_slices: true + emit_pointers_for_null_types: true + + - engine: "postgresql" + queries: "internal/shared/auditlog/queries/queries.sql" + schema: "migrations" + gen: + go: + package: "sqlc" + out: "internal/shared/auditlog/sqlc" + sql_package: "database/sql" + emit_json_tags: true + emit_empty_slices: true + emit_pointers_for_null_types: true +``` + +- [ ] **Step 2: Update `.gitignore`** + +Add after the swagger section: + +``` +# SQLc generated code (regenerate with `make sqlc`) +**/infrastructure/persistence/sqlc/ +internal/shared/auditlog/sqlc/ +``` + +- [ ] **Step 3: Create query directories** + +```bash +mkdir -p internal/todo/infrastructure/persistence/queries +mkdir -p internal/authentication/infrastructure/persistence/queries +mkdir -p internal/authorization/infrastructure/persistence/queries +mkdir -p internal/user/infrastructure/persistence/queries +mkdir -p internal/shared/auditlog/queries +``` + +- [ ] **Step 4: Create minimal placeholder query files for all domains except todo (which gets real queries in Task 2)** + +Create each file with a single placeholder query so `make sqlc` succeeds: + +`internal/authentication/infrastructure/persistence/queries/queries.sql`: +```sql +-- name: Ping :one +SELECT 1; +``` + +`internal/authorization/infrastructure/persistence/queries/queries.sql`: +```sql +-- name: Ping :one +SELECT 1; +``` + +`internal/user/infrastructure/persistence/queries/queries.sql`: +```sql +-- name: Ping :one +SELECT 1; +``` + +`internal/shared/auditlog/queries/queries.sql`: +```sql +-- name: Ping :one +SELECT 1; +``` + +- [ ] **Step 5: Run `make sqlc`** + +```bash +make sqlc +``` + +Expected: Clean exit, generates 5 sqlc packages. + +- [ ] **Step 6: Verify build still passes** + +```bash +go build ./... +``` + +Expected: Clean build. The existing hand-written repos still work, and the new sqlc packages exist but aren't imported yet. + +- [ ] **Step 7: Commit** + +```bash +git add sqlc.yaml .gitignore internal/todo/infrastructure/persistence/queries/ internal/authentication/infrastructure/persistence/queries/ internal/authorization/infrastructure/persistence/queries/ internal/user/infrastructure/persistence/queries/ internal/shared/auditlog/queries/ internal/todo/infrastructure/persistence/sqlc/ internal/authentication/infrastructure/persistence/sqlc/ internal/authorization/infrastructure/persistence/sqlc/ internal/user/infrastructure/persistence/sqlc/ internal/shared/auditlog/sqlc/ +git commit -m "feat: configure multi-domain sqlc with 5 generation targets" +``` + +--- + +### Task 2: Migrate Todo Domain + +**Files:** +- Create: `internal/todo/infrastructure/persistence/queries/todo.sql` — real todo queries +- Delete: `internal/todo/infrastructure/persistence/queries.sql` — old single-file +- Modify: `internal/todo/infrastructure/persistence/todo_repository.go` — refactor to sqlc + +- [ ] **Step 1: Move existing queries to new location** + +Read the existing `internal/todo/infrastructure/persistence/queries.sql` and write it to `internal/todo/infrastructure/persistence/queries/todo.sql` (content is identical): + +```sql +-- name: CreateTodo :exec +INSERT INTO todos (id, title, description, completed, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6); + +-- name: GetTodoByID :one +SELECT id, title, description, completed, created_at, updated_at, deleted_at +FROM todos +WHERE id = $1 AND deleted_at IS NULL; + +-- name: ListTodos :many +SELECT id, title, description, completed, created_at, updated_at, deleted_at +FROM todos +WHERE deleted_at IS NULL +ORDER BY created_at DESC +LIMIT $1 OFFSET $2; + +-- name: CountTodos :one +SELECT COUNT(*) FROM todos WHERE deleted_at IS NULL; + +-- name: UpdateTodo :exec +UPDATE todos +SET title = $2, description = $3, completed = $4, updated_at = $5 +WHERE id = $1 AND deleted_at IS NULL; + +-- name: SoftDeleteTodo :exec +UPDATE todos +SET deleted_at = NOW(), updated_at = NOW() +WHERE id = $1 AND deleted_at IS NULL; + +-- name: SearchTodos :many +SELECT id, title, description, completed, created_at, updated_at, deleted_at +FROM todos +WHERE deleted_at IS NULL AND (title ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%') +ORDER BY created_at DESC +LIMIT $2 OFFSET $3; + +-- name: CountSearchTodos :one +SELECT COUNT(*) FROM todos +WHERE deleted_at IS NULL AND (title ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%'); +``` + +- [ ] **Step 2: Delete old queries.sql** + +```bash +rm internal/todo/infrastructure/persistence/queries.sql +``` + +- [ ] **Step 3: Run `make sqlc` to regenerate** + +```bash +make sqlc +``` + +Expected: Clean exit. Todo sqlc package now has real query methods. + +- [ ] **Step 4: Rewrite `internal/todo/infrastructure/persistence/todo_repository.go`** + +```go +package persistence + +import ( + "context" + "database/sql" + "fmt" + + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/todo/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/todo/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/todo/infrastructure/persistence/sqlc" + "github.com/google/uuid" +) + +type todoRepository struct { + db *sql.DB +} + +func NewTodoRepository(db *sql.DB) repository.TodoRepository { + return &todoRepository{db: db} +} + +func (r *todoRepository) Create(ctx context.Context, todo *entity.Todo) error { + q := sqlc.New(r.db) + err := q.CreateTodo(ctx, sqlc.CreateTodoParams{ + ID: todo.ID, + Title: todo.Title, + Description: todo.Description, + Completed: todo.Completed, + CreatedAt: todo.CreatedAt, + UpdatedAt: todo.UpdatedAt, + }) + if err != nil { + return fmt.Errorf("insert todo: %w", err) + } + return nil +} + +func (r *todoRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.Todo, error) { + q := sqlc.New(r.db) + row, err := q.GetTodoByID(ctx, id) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("todo not found") + } + if err != nil { + return nil, fmt.Errorf("get todo: %w", err) + } + return mapSqlcTodoToEntity(row), nil +} + +func (r *todoRepository) GetAll(ctx context.Context, offset, limit int) ([]*entity.Todo, int, error) { + q := sqlc.New(r.db) + + total, err := q.CountTodos(ctx) + if err != nil { + return nil, 0, fmt.Errorf("count todos: %w", err) + } + + rows, err := q.ListTodos(ctx, sqlc.ListTodosParams{ + Limit: int32(limit), + Offset: int32(offset), + }) + if err != nil { + return nil, 0, fmt.Errorf("query todos: %w", err) + } + + todos := make([]*entity.Todo, len(rows)) + for i, row := range rows { + todos[i] = mapSqlcTodoToEntity(row) + } + return todos, int(total), nil +} + +func (r *todoRepository) Update(ctx context.Context, todo *entity.Todo) error { + q := sqlc.New(r.db) + result, err := q.UpdateTodo(ctx, sqlc.UpdateTodoParams{ + ID: todo.ID, + Title: todo.Title, + Description: todo.Description, + Completed: todo.Completed, + UpdatedAt: todo.UpdatedAt, + }) + if err != nil { + return fmt.Errorf("update todo: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rows == 0 { + return fmt.Errorf("todo not found") + } + return nil +} + +func (r *todoRepository) Delete(ctx context.Context, id uuid.UUID) error { + q := sqlc.New(r.db) + result, err := q.SoftDeleteTodo(ctx, id) + if err != nil { + return fmt.Errorf("delete todo: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if rows == 0 { + return fmt.Errorf("todo not found") + } + return nil +} + +func (r *todoRepository) Search(ctx context.Context, query string, offset, limit int) ([]*entity.Todo, int, error) { + q := sqlc.New(r.db) + searchPattern := "%" + query + "%" + + total, err := q.CountSearchTodos(ctx, searchPattern) + if err != nil { + return nil, 0, fmt.Errorf("count search results: %w", err) + } + + rows, err := q.SearchTodos(ctx, sqlc.SearchTodosParams{ + Column1: searchPattern, + Limit: int32(limit), + Offset: int32(offset), + }) + if err != nil { + return nil, 0, fmt.Errorf("search todos: %w", err) + } + + todos := make([]*entity.Todo, len(rows)) + for i, row := range rows { + todos[i] = mapSqlcTodoToEntity(row) + } + return todos, int(total), nil +} + +func mapSqlcTodoToEntity(row sqlc.Todo) *entity.Todo { + return &entity.Todo{ + Entity: domain.Entity{ + ID: row.ID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + DeletedAt: row.DeletedAt, + }, + Title: row.Title, + Description: row.Description, + Completed: row.Completed, + } +} +``` + +**Note on `SearchTodos` column naming:** Sqlc may use `Column1`, `Limit`, `Offset` as parameter field names for the `$1`, `$2`, `$3` placeholders when they appear in complex expressions. After generating, check the actual generated param struct in `internal/todo/infrastructure/persistence/sqlc/queries.sql.go` and adjust the Search call if the field names differ (e.g., `Column1` might be `Search` or `Pattern`). + +- [ ] **Step 5: Verify build** + +```bash +go build ./... +``` + +Expected: Clean build. + +- [ ] **Step 6: Commit** + +```bash +git add internal/todo/infrastructure/persistence +git commit -m "feat(todo): migrate todo repository from database/sql to sqlc" +``` + +--- + +### Task 3: Migrate Authentication Domain + +**Files:** +- Modify: `sqlc.yaml` — auth block already exists from Task 1 +- Modify: `internal/authentication/infrastructure/persistence/queries/queries.sql` — replace placeholder with real queries +- Modify: `internal/authentication/infrastructure/persistence/user_repository.go` — refactor to sqlc +- Modify: `internal/authentication/infrastructure/persistence/refresh_token_repository.go` — refactor to sqlc + +- [ ] **Step 1: Write real queries to `internal/authentication/infrastructure/persistence/queries/queries.sql`** + +```sql +-- name: CreateUser :exec +INSERT INTO users (id, email, password, name, is_active, failed_login_attempts, locked_until, email_verified, email_verify_token, email_verify_expires, password_reset_token, password_reset_expires, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14); + +-- name: GetUserByID :one +SELECT id, email, password, name, is_active, failed_login_attempts, locked_until, email_verified, email_verify_token, email_verify_expires, password_reset_token, password_reset_expires, created_at, updated_at, deleted_at +FROM users WHERE id = $1 AND deleted_at IS NULL; + +-- name: GetUserByEmail :one +SELECT id, email, password, name, is_active, failed_login_attempts, locked_until, email_verified, email_verify_token, email_verify_expires, password_reset_token, password_reset_expires, created_at, updated_at, deleted_at +FROM users WHERE email = $1 AND deleted_at IS NULL; + +-- name: GetUserByVerifyToken :one +SELECT id, email, password, name, is_active, failed_login_attempts, locked_until, email_verified, email_verify_token, email_verify_expires, password_reset_token, password_reset_expires, created_at, updated_at, deleted_at +FROM users WHERE email_verify_token = $1 AND deleted_at IS NULL; + +-- name: GetUserByResetToken :one +SELECT id, email, password, name, is_active, failed_login_attempts, locked_until, email_verified, email_verify_token, email_verify_expires, password_reset_token, password_reset_expires, created_at, updated_at, deleted_at +FROM users WHERE password_reset_token = $1 AND deleted_at IS NULL; + +-- name: UpdateUser :exec +UPDATE users SET email = $2, password = $3, name = $4, is_active = $5, updated_at = $6, failed_login_attempts = $7, locked_until = $8, email_verified = $9, email_verify_token = $10, email_verify_expires = $11, password_reset_token = $12, password_reset_expires = $13 WHERE id = $1 AND deleted_at IS NULL; + +-- name: CreateRefreshToken :exec +INSERT INTO refresh_tokens (id, user_id, token, expires_at, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6); + +-- name: GetRefreshTokenByToken :one +SELECT id, user_id, token, expires_at, revoked_at, created_at, updated_at, deleted_at +FROM refresh_tokens WHERE token = $1 AND deleted_at IS NULL; + +-- name: GetRefreshTokensByUserID :many +SELECT id, user_id, token, expires_at, revoked_at, created_at, updated_at, deleted_at +FROM refresh_tokens WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC; + +-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens SET revoked_at = NOW(), updated_at = NOW() WHERE token = $1 AND deleted_at IS NULL AND revoked_at IS NULL; + +-- name: RevokeAllRefreshTokensByUserID :exec +UPDATE refresh_tokens SET revoked_at = NOW(), updated_at = NOW() WHERE user_id = $1 AND deleted_at IS NULL AND revoked_at IS NULL; + +-- name: DeleteExpiredRefreshTokens :exec +UPDATE refresh_tokens SET deleted_at = NOW() WHERE expires_at < NOW() AND deleted_at IS NULL; +``` + +- [ ] **Step 2: Run `make sqlc` to regenerate** + +```bash +make sqlc +``` + +Expected: Clean exit. Auth sqlc package now has user + refresh_token query methods. + +- [ ] **Step 3: Rewrite `internal/authentication/infrastructure/persistence/user_repository.go`** + +Read the generated sqlc types first to confirm parameter struct names. + +```go +package persistence + +import ( + "context" + "database/sql" + "fmt" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/authentication/infrastructure/persistence/sqlc" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/google/uuid" +) + +type userRepository struct { + db *sql.DB +} + +func NewUserRepository(db *sql.DB) repository.UserRepository { + return &userRepository{db: db} +} + +func (r *userRepository) Create(ctx context.Context, user *entity.User) error { + q := sqlc.New(r.db) + err := q.CreateUser(ctx, sqlc.CreateUserParams{ + ID: user.ID, + Email: user.Email, + Password: user.Password, + Name: user.Name, + IsActive: user.IsActive, + FailedLoginAttempts: int32(user.FailedLoginAttempts), + LockedUntil: user.LockedUntil, + EmailVerified: user.EmailVerified, + EmailVerifyToken: user.EmailVerifyToken, + EmailVerifyExpires: user.EmailVerifyExpires, + PasswordResetToken: user.PasswordResetToken, + PasswordResetExpires: user.PasswordResetExpires, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + }) + if err != nil { + return fmt.Errorf("insert user: %w", err) + } + return nil +} + +func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.User, error) { + q := sqlc.New(r.db) + row, err := q.GetUserByID(ctx, id) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("user not found") + } + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + return mapSqlcUserToEntity(row), nil +} + +func (r *userRepository) GetByEmail(ctx context.Context, email string) (*entity.User, error) { + q := sqlc.New(r.db) + row, err := q.GetUserByEmail(ctx, email) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("user not found") + } + if err != nil { + return nil, fmt.Errorf("get user by email: %w", err) + } + return mapSqlcUserToEntity(row), nil +} + +func (r *userRepository) GetByVerifyToken(ctx context.Context, token string) (*entity.User, error) { + q := sqlc.New(r.db) + row, err := q.GetUserByVerifyToken(ctx, token) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("user not found") + } + if err != nil { + return nil, fmt.Errorf("get user by verify token: %w", err) + } + return mapSqlcUserToEntity(row), nil +} + +func (r *userRepository) GetByResetToken(ctx context.Context, token string) (*entity.User, error) { + q := sqlc.New(r.db) + row, err := q.GetUserByResetToken(ctx, token) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("user not found") + } + if err != nil { + return nil, fmt.Errorf("get user by reset token: %w", err) + } + return mapSqlcUserToEntity(row), nil +} + +func (r *userRepository) Update(ctx context.Context, user *entity.User) error { + q := sqlc.New(r.db) + result, err := q.UpdateUser(ctx, sqlc.UpdateUserParams{ + ID: user.ID, + Email: user.Email, + Password: user.Password, + Name: user.Name, + IsActive: user.IsActive, + UpdatedAt: user.UpdatedAt, + FailedLoginAttempts: int32(user.FailedLoginAttempts), + LockedUntil: user.LockedUntil, + EmailVerified: user.EmailVerified, + EmailVerifyToken: user.EmailVerifyToken, + EmailVerifyExpires: user.EmailVerifyExpires, + PasswordResetToken: user.PasswordResetToken, + PasswordResetExpires: user.PasswordResetExpires, + }) + if err != nil { + return fmt.Errorf("update user: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("user not found") + } + return nil +} + +func mapSqlcUserToEntity(row sqlc.User) *entity.User { + return &entity.User{ + Entity: domain.Entity{ + ID: row.ID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + DeletedAt: row.DeletedAt, + }, + Email: row.Email, + Password: row.Password, + Name: row.Name, + IsActive: row.IsActive, + FailedLoginAttempts: int(row.FailedLoginAttempts), + LockedUntil: row.LockedUntil, + EmailVerified: row.EmailVerified, + EmailVerifyToken: row.EmailVerifyToken, + EmailVerifyExpires: row.EmailVerifyExpires, + PasswordResetToken: row.PasswordResetToken, + PasswordResetExpires: row.PasswordResetExpires, + } +} +``` + +- [ ] **Step 4: Rewrite `internal/authentication/infrastructure/persistence/refresh_token_repository.go`** + +```go +package persistence + +import ( + "context" + "database/sql" + "fmt" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/authentication/infrastructure/persistence/sqlc" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/google/uuid" +) + +type refreshTokenRepository struct { + db *sql.DB +} + +func NewRefreshTokenRepository(db *sql.DB) repository.RefreshTokenRepository { + return &refreshTokenRepository{db: db} +} + +func (r *refreshTokenRepository) Create(ctx context.Context, token *entity.RefreshToken) error { + q := sqlc.New(r.db) + err := q.CreateRefreshToken(ctx, sqlc.CreateRefreshTokenParams{ + ID: token.ID, + UserID: token.UserID, + Token: token.Token, + ExpiresAt: token.ExpiresAt, + CreatedAt: token.CreatedAt, + UpdatedAt: token.UpdatedAt, + }) + if err != nil { + return fmt.Errorf("insert refresh token: %w", err) + } + return nil +} + +func (r *refreshTokenRepository) GetByToken(ctx context.Context, token string) (*entity.RefreshToken, error) { + q := sqlc.New(r.db) + row, err := q.GetRefreshTokenByToken(ctx, token) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("refresh token not found") + } + if err != nil { + return nil, fmt.Errorf("get refresh token: %w", err) + } + return mapSqlcRefreshTokenToEntity(row), nil +} + +func (r *refreshTokenRepository) GetByUserID(ctx context.Context, userID uuid.UUID) ([]*entity.RefreshToken, error) { + q := sqlc.New(r.db) + rows, err := q.GetRefreshTokensByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get refresh tokens: %w", err) + } + tokens := make([]*entity.RefreshToken, len(rows)) + for i, row := range rows { + tokens[i] = mapSqlcRefreshTokenToEntity(row) + } + return tokens, nil +} + +func (r *refreshTokenRepository) Revoke(ctx context.Context, token string) error { + q := sqlc.New(r.db) + err := q.RevokeRefreshToken(ctx, token) + if err != nil { + return fmt.Errorf("revoke refresh token: %w", err) + } + return nil +} + +func (r *refreshTokenRepository) RevokeAllByUserID(ctx context.Context, userID uuid.UUID) error { + q := sqlc.New(r.db) + err := q.RevokeAllRefreshTokensByUserID(ctx, userID) + if err != nil { + return fmt.Errorf("revoke all refresh tokens: %w", err) + } + return nil +} + +func (r *refreshTokenRepository) DeleteExpired(ctx context.Context) error { + q := sqlc.New(r.db) + err := q.DeleteExpiredRefreshTokens(ctx) + if err != nil { + return fmt.Errorf("delete expired tokens: %w", err) + } + return nil +} + +func mapSqlcRefreshTokenToEntity(row sqlc.RefreshToken) *entity.RefreshToken { + return &entity.RefreshToken{ + Entity: domain.Entity{ + ID: row.ID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + DeletedAt: row.DeletedAt, + }, + UserID: row.UserID, + Token: row.Token, + ExpiresAt: row.ExpiresAt, + RevokedAt: row.RevokedAt, + } +} +``` + +- [ ] **Step 5: Verify build** + +```bash +go build ./... +``` + +Expected: Clean build. + +- [ ] **Step 6: Commit** + +```bash +git add sqlc.yaml internal/authentication/infrastructure/persistence +git commit -m "feat(authentication): migrate user and refresh_token repositories from database/sql to sqlc" +``` + +--- + +### Task 4: Migrate Authorization Domain (4 repos) + +**Files:** +- Modify: `internal/authorization/infrastructure/persistence/queries/queries.sql` — replace placeholder with real queries +- Modify: `internal/authorization/infrastructure/persistence/role_repository.go` — refactor to sqlc +- Modify: `internal/authorization/infrastructure/persistence/permission_repository.go` — refactor to sqlc +- Modify: `internal/authorization/infrastructure/persistence/role_permission_repository.go` — refactor to sqlc +- Modify: `internal/authorization/infrastructure/persistence/user_role_repository.go` — refactor to sqlc + +- [ ] **Step 1: Write real queries to `internal/authorization/infrastructure/persistence/queries/queries.sql`** + +```sql +-- name: CreateRole :exec +INSERT INTO roles (id, name, description, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5); + +-- name: GetRoleByID :one +SELECT id, name, description, created_at, updated_at, deleted_at +FROM roles WHERE id = $1 AND deleted_at IS NULL; + +-- name: GetRoleByName :one +SELECT id, name, description, created_at, updated_at, deleted_at +FROM roles WHERE name = $1 AND deleted_at IS NULL; + +-- name: ListRoles :many +SELECT id, name, description, created_at, updated_at, deleted_at +FROM roles WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2; + +-- name: CountRoles :one +SELECT COUNT(*) FROM roles WHERE deleted_at IS NULL; + +-- name: UpdateRole :exec +UPDATE roles SET name = $2, description = $3, updated_at = $4 WHERE id = $1 AND deleted_at IS NULL; + +-- name: DeleteRole :exec +UPDATE roles SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL; + +-- name: CreatePermission :exec +INSERT INTO permissions (id, name, description, resource, action, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7); + +-- name: GetPermissionByID :one +SELECT id, name, description, resource, action, created_at, updated_at, deleted_at +FROM permissions WHERE id = $1 AND deleted_at IS NULL; + +-- name: GetPermissionByName :one +SELECT id, name, description, resource, action, created_at, updated_at, deleted_at +FROM permissions WHERE name = $1 AND deleted_at IS NULL; + +-- name: ListPermissions :many +SELECT id, name, description, resource, action, created_at, updated_at, deleted_at +FROM permissions WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2; + +-- name: CountPermissions :one +SELECT COUNT(*) FROM permissions WHERE deleted_at IS NULL; + +-- name: UpdatePermission :exec +UPDATE permissions SET name = $2, description = $3, resource = $4, action = $5, updated_at = $6 WHERE id = $1 AND deleted_at IS NULL; + +-- name: DeletePermission :exec +UPDATE permissions SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL; + +-- name: AssignRolePermission :exec +INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2) ON CONFLICT (role_id, permission_id) DO NOTHING; + +-- name: RemoveRolePermission :exec +DELETE FROM role_permissions WHERE role_id = $1 AND permission_id = $2; + +-- name: GetRolePermissionsByRoleID :many +SELECT role_id, permission_id FROM role_permissions WHERE role_id = $1; + +-- name: GetPermissionsByRoleID :many +SELECT p.id, p.name, p.description, p.resource, p.action, p.created_at, p.updated_at, p.deleted_at +FROM permissions p +JOIN role_permissions rp ON p.id = rp.permission_id +WHERE rp.role_id = $1 AND p.deleted_at IS NULL +ORDER BY p.created_at DESC; + +-- name: AssignUserRole :exec +INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT (user_id, role_id) DO NOTHING; + +-- name: RemoveUserRole :exec +DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2; + +-- name: GetUserRolesByUserID :many +SELECT user_id, role_id FROM user_roles WHERE user_id = $1; + +-- name: GetRolesByUserID :many +SELECT r.id, r.name, r.description, r.created_at, r.updated_at, r.deleted_at +FROM roles r +JOIN user_roles ur ON r.id = ur.role_id +WHERE ur.user_id = $1 AND r.deleted_at IS NULL +ORDER BY r.created_at DESC; +``` + +- [ ] **Step 2: Run `make sqlc` to regenerate** + +```bash +make sqlc +``` + +Expected: Clean exit. Authorization sqlc package now has role + permission + role_permission + user_role query methods. + +- [ ] **Step 3: Rewrite `internal/authorization/infrastructure/persistence/role_repository.go`** + +```go +package persistence + +import ( + "context" + "database/sql" + "fmt" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/persistence/sqlc" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/google/uuid" +) + +type roleRepository struct { + db *sql.DB +} + +func NewRoleRepository(db *sql.DB) repository.RoleRepository { + return &roleRepository{db: db} +} + +func (r *roleRepository) Create(ctx context.Context, role *entity.Role) error { + q := sqlc.New(r.db) + err := q.CreateRole(ctx, sqlc.CreateRoleParams{ + ID: role.ID, + Name: role.Name, + Description: role.Description, + CreatedAt: role.CreatedAt, + UpdatedAt: role.UpdatedAt, + }) + if err != nil { + return fmt.Errorf("insert role: %w", err) + } + return nil +} + +func (r *roleRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.Role, error) { + q := sqlc.New(r.db) + row, err := q.GetRoleByID(ctx, id) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("role not found") + } + if err != nil { + return nil, fmt.Errorf("get role: %w", err) + } + return mapSqlcRoleToEntity(row), nil +} + +func (r *roleRepository) GetByName(ctx context.Context, name string) (*entity.Role, error) { + q := sqlc.New(r.db) + row, err := q.GetRoleByName(ctx, name) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("role not found") + } + if err != nil { + return nil, fmt.Errorf("get role by name: %w", err) + } + return mapSqlcRoleToEntity(row), nil +} + +func (r *roleRepository) GetAll(ctx context.Context, offset, limit int) ([]*entity.Role, int, error) { + q := sqlc.New(r.db) + + total, err := q.CountRoles(ctx) + if err != nil { + return nil, 0, fmt.Errorf("count roles: %w", err) + } + + rows, err := q.ListRoles(ctx, sqlc.ListRolesParams{ + Limit: int32(limit), + Offset: int32(offset), + }) + if err != nil { + return nil, 0, fmt.Errorf("query roles: %w", err) + } + + roles := make([]*entity.Role, len(rows)) + for i, row := range rows { + roles[i] = mapSqlcRoleToEntity(row) + } + return roles, int(total), nil +} + +func (r *roleRepository) Update(ctx context.Context, role *entity.Role) error { + q := sqlc.New(r.db) + result, err := q.UpdateRole(ctx, sqlc.UpdateRoleParams{ + ID: role.ID, + Name: role.Name, + Description: role.Description, + UpdatedAt: role.UpdatedAt, + }) + if err != nil { + return fmt.Errorf("update role: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("role not found") + } + return nil +} + +func (r *roleRepository) Delete(ctx context.Context, id uuid.UUID) error { + q := sqlc.New(r.db) + result, err := q.DeleteRole(ctx, id) + if err != nil { + return fmt.Errorf("delete role: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("role not found") + } + return nil +} + +func mapSqlcRoleToEntity(row sqlc.Role) *entity.Role { + return &entity.Role{ + Entity: domain.Entity{ + ID: row.ID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + DeletedAt: row.DeletedAt, + }, + Name: row.Name, + Description: row.Description, + } +} +``` + +- [ ] **Step 4: Rewrite `internal/authorization/infrastructure/persistence/permission_repository.go`** + +```go +package persistence + +import ( + "context" + "database/sql" + "fmt" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/persistence/sqlc" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/google/uuid" +) + +type permissionRepository struct { + db *sql.DB +} + +func NewPermissionRepository(db *sql.DB) repository.PermissionRepository { + return &permissionRepository{db: db} +} + +func (r *permissionRepository) Create(ctx context.Context, perm *entity.Permission) error { + q := sqlc.New(r.db) + err := q.CreatePermission(ctx, sqlc.CreatePermissionParams{ + ID: perm.ID, + Name: perm.Name, + Description: perm.Description, + Resource: perm.Resource, + Action: perm.Action, + CreatedAt: perm.CreatedAt, + UpdatedAt: perm.UpdatedAt, + }) + if err != nil { + return fmt.Errorf("insert permission: %w", err) + } + return nil +} + +func (r *permissionRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.Permission, error) { + q := sqlc.New(r.db) + row, err := q.GetPermissionByID(ctx, id) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("permission not found") + } + if err != nil { + return nil, fmt.Errorf("get permission: %w", err) + } + return mapSqlcPermissionToEntity(row), nil +} + +func (r *permissionRepository) GetByName(ctx context.Context, name string) (*entity.Permission, error) { + q := sqlc.New(r.db) + row, err := q.GetPermissionByName(ctx, name) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("permission not found") + } + if err != nil { + return nil, fmt.Errorf("get permission by name: %w", err) + } + return mapSqlcPermissionToEntity(row), nil +} + +func (r *permissionRepository) GetAll(ctx context.Context, offset, limit int) ([]*entity.Permission, int, error) { + q := sqlc.New(r.db) + + total, err := q.CountPermissions(ctx) + if err != nil { + return nil, 0, fmt.Errorf("count permissions: %w", err) + } + + rows, err := q.ListPermissions(ctx, sqlc.ListPermissionsParams{ + Limit: int32(limit), + Offset: int32(offset), + }) + if err != nil { + return nil, 0, fmt.Errorf("query permissions: %w", err) + } + + perms := make([]*entity.Permission, len(rows)) + for i, row := range rows { + perms[i] = mapSqlcPermissionToEntity(row) + } + return perms, int(total), nil +} + +func (r *permissionRepository) Update(ctx context.Context, perm *entity.Permission) error { + q := sqlc.New(r.db) + result, err := q.UpdatePermission(ctx, sqlc.UpdatePermissionParams{ + ID: perm.ID, + Name: perm.Name, + Description: perm.Description, + Resource: perm.Resource, + Action: perm.Action, + UpdatedAt: perm.UpdatedAt, + }) + if err != nil { + return fmt.Errorf("update permission: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("permission not found") + } + return nil +} + +func (r *permissionRepository) Delete(ctx context.Context, id uuid.UUID) error { + q := sqlc.New(r.db) + result, err := q.DeletePermission(ctx, id) + if err != nil { + return fmt.Errorf("delete permission: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("permission not found") + } + return nil +} + +func mapSqlcPermissionToEntity(row sqlc.Permission) *entity.Permission { + return &entity.Permission{ + Entity: domain.Entity{ + ID: row.ID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + DeletedAt: row.DeletedAt, + }, + Name: row.Name, + Description: row.Description, + Resource: row.Resource, + Action: row.Action, + } +} +``` + +- [ ] **Step 5: Rewrite `internal/authorization/infrastructure/persistence/role_permission_repository.go`** + +```go +package persistence + +import ( + "context" + "database/sql" + "fmt" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/persistence/sqlc" + "github.com/google/uuid" +) + +type rolePermissionRepository struct { + db *sql.DB +} + +func NewRolePermissionRepository(db *sql.DB) repository.RolePermissionRepository { + return &rolePermissionRepository{db: db} +} + +func (r *rolePermissionRepository) Assign(ctx context.Context, rp entity.RolePermission) error { + q := sqlc.New(r.db) + err := q.AssignRolePermission(ctx, sqlc.AssignRolePermissionParams{ + RoleID: rp.RoleID, + PermissionID: rp.PermissionID, + }) + if err != nil { + return fmt.Errorf("assign permission: %w", err) + } + return nil +} + +func (r *rolePermissionRepository) Remove(ctx context.Context, roleID, permissionID uuid.UUID) error { + q := sqlc.New(r.db) + err := q.RemoveRolePermission(ctx, sqlc.RemoveRolePermissionParams{ + RoleID: roleID, + PermissionID: permissionID, + }) + if err != nil { + return fmt.Errorf("remove permission: %w", err) + } + return nil +} + +func (r *rolePermissionRepository) GetByRoleID(ctx context.Context, roleID uuid.UUID) ([]entity.RolePermission, error) { + q := sqlc.New(r.db) + rows, err := q.GetRolePermissionsByRoleID(ctx, roleID) + if err != nil { + return nil, fmt.Errorf("get role permissions: %w", err) + } + rps := make([]entity.RolePermission, len(rows)) + for i, row := range rows { + rps[i] = mapSqlcRolePermissionToEntity(row) + } + return rps, nil +} + +func (r *rolePermissionRepository) GetPermissionsByRoleID(ctx context.Context, roleID uuid.UUID) ([]*entity.Permission, error) { + q := sqlc.New(r.db) + rows, err := q.GetPermissionsByRoleID(ctx, roleID) + if err != nil { + return nil, fmt.Errorf("get permissions by role: %w", err) + } + perms := make([]*entity.Permission, len(rows)) + for i, row := range rows { + perms[i] = mapSqlcPermissionToEntity(row) + } + return perms, nil +} + +func mapSqlcRolePermissionToEntity(row sqlc.RolePermission) entity.RolePermission { + return entity.RolePermission{ + RoleID: row.RoleID, + PermissionID: row.PermissionID, + } +} +``` + +- [ ] **Step 6: Rewrite `internal/authorization/infrastructure/persistence/user_role_repository.go`** + +```go +package persistence + +import ( + "context" + "database/sql" + "fmt" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/persistence/sqlc" + "github.com/google/uuid" +) + +type userRoleRepository struct { + db *sql.DB +} + +func NewUserRoleRepository(db *sql.DB) repository.UserRoleRepository { + return &userRoleRepository{db: db} +} + +func (r *userRoleRepository) Assign(ctx context.Context, ur entity.UserRole) error { + q := sqlc.New(r.db) + err := q.AssignUserRole(ctx, sqlc.AssignUserRoleParams{ + UserID: ur.UserID, + RoleID: ur.RoleID, + }) + if err != nil { + return fmt.Errorf("assign role: %w", err) + } + return nil +} + +func (r *userRoleRepository) Remove(ctx context.Context, userID, roleID uuid.UUID) error { + q := sqlc.New(r.db) + err := q.RemoveUserRole(ctx, sqlc.RemoveUserRoleParams{ + UserID: userID, + RoleID: roleID, + }) + if err != nil { + return fmt.Errorf("remove role: %w", err) + } + return nil +} + +func (r *userRoleRepository) GetByUserID(ctx context.Context, userID uuid.UUID) ([]entity.UserRole, error) { + q := sqlc.New(r.db) + rows, err := q.GetUserRolesByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get user roles: %w", err) + } + urs := make([]entity.UserRole, len(rows)) + for i, row := range rows { + urs[i] = mapSqlcUserRoleToEntity(row) + } + return urs, nil +} + +func (r *userRoleRepository) GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]*entity.Role, error) { + q := sqlc.New(r.db) + rows, err := q.GetRolesByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get roles by user: %w", err) + } + roles := make([]*entity.Role, len(rows)) + for i, row := range rows { + roles[i] = mapSqlcRoleToEntity(row) + } + return roles, nil +} + +func mapSqlcUserRoleToEntity(row sqlc.UserRole) entity.UserRole { + return entity.UserRole{ + UserID: row.UserID, + RoleID: row.RoleID, + } +} +``` + +- [ ] **Step 7: Verify build** + +```bash +go build ./... +``` + +Expected: Clean build. + +- [ ] **Step 8: Commit** + +```bash +git add sqlc.yaml internal/authorization/infrastructure/persistence +git commit -m "feat(authorization): migrate role, permission, role_permission, user_role repositories from database/sql to sqlc" +``` + +--- + +### Task 5: Migrate User Domain + +**Files:** +- Modify: `internal/user/infrastructure/persistence/queries/queries.sql` — replace placeholder with real queries +- Modify: `internal/user/infrastructure/persistence/user_repository.go` — refactor to sqlc + +- [ ] **Step 1: Write real queries to `internal/user/infrastructure/persistence/queries/queries.sql`** + +```sql +-- name: CountUsers :one +SELECT COUNT(*) FROM users WHERE deleted_at IS NULL; + +-- name: ListUsers :many +SELECT id, email, password, name, is_active, failed_login_attempts, locked_until, email_verified, email_verify_token, email_verify_expires, password_reset_token, password_reset_expires, created_at, updated_at, deleted_at +FROM users WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2; + +-- name: GetUserByID :one +SELECT id, email, password, name, is_active, failed_login_attempts, locked_until, email_verified, email_verify_token, email_verify_expires, password_reset_token, password_reset_expires, created_at, updated_at, deleted_at +FROM users WHERE id = $1 AND deleted_at IS NULL; + +-- name: UpdateUser :exec +UPDATE users SET email = $2, name = $3, is_active = $4, updated_at = $5, deleted_at = $6 WHERE id = $1; + +-- name: DeleteUser :exec +UPDATE users SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL; +``` + +**Note on user domain queries:** We select all columns from `users` (same as authentication domain) for schema consistency — sqlc generates a single `User` struct per `sqlc/` package. The mapping function only populates fields used by the user domain service. + +- [ ] **Step 2: Run `make sqlc` to regenerate** + +```bash +make sqlc +``` + +Expected: Clean exit. User sqlc package now has user list/get/update/delete query methods. + +- [ ] **Step 3: Rewrite `internal/user/infrastructure/persistence/user_repository.go`** + +```go +package persistence + +import ( + "context" + "database/sql" + "fmt" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/user/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/user/infrastructure/persistence/sqlc" + "github.com/google/uuid" +) + +type userRepository struct { + db *sql.DB +} + +func NewUserRepository(db *sql.DB) repository.UserRepository { + return &userRepository{db: db} +} + +func (r *userRepository) List(ctx context.Context, offset, limit int) ([]*entity.User, int, error) { + q := sqlc.New(r.db) + + total, err := q.CountUsers(ctx) + if err != nil { + return nil, 0, fmt.Errorf("count users: %w", err) + } + + rows, err := q.ListUsers(ctx, sqlc.ListUsersParams{ + Limit: int32(limit), + Offset: int32(offset), + }) + if err != nil { + return nil, 0, fmt.Errorf("list users: %w", err) + } + + users := make([]*entity.User, len(rows)) + for i, row := range rows { + users[i] = mapSqlcUserToEntityForAdmin(row) + } + return users, int(total), nil +} + +func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.User, error) { + q := sqlc.New(r.db) + row, err := q.GetUserByID(ctx, id) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("user not found") + } + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + return mapSqlcUserToEntityForAdmin(row), nil +} + +func (r *userRepository) Update(ctx context.Context, user *entity.User) error { + q := sqlc.New(r.db) + result, err := q.UpdateUser(ctx, sqlc.UpdateUserParams{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + IsActive: user.IsActive, + UpdatedAt: user.UpdatedAt, + DeletedAt: user.DeletedAt, + }) + if err != nil { + return fmt.Errorf("update user: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("user not found") + } + return nil +} + +func (r *userRepository) Delete(ctx context.Context, id uuid.UUID) error { + q := sqlc.New(r.db) + result, err := q.DeleteUser(ctx, id) + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("user not found") + } + return nil +} + +func mapSqlcUserToEntityForAdmin(row sqlc.User) *entity.User { + return &entity.User{ + Entity: domain.Entity{ + ID: row.ID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + DeletedAt: row.DeletedAt, + }, + Email: row.Email, + Name: row.Name, + IsActive: row.IsActive, + } +} +``` + +**Note on `UpdateUser` field mapping:** The sqlc-generated `UpdateUserParams` struct for the user domain's `UpdateUser` query only contains the fields used in the SET clause (`email`, `name`, `is_active`, `updated_at`, `deleted_at`) and WHERE clause (`id`). Check the exact param struct in `internal/user/infrastructure/persistence/sqlc/queries.sql.go` after generation and adjust the field names if needed. + +- [ ] **Step 4: Verify build** + +```bash +go build ./... +``` + +Expected: Clean build. + +- [ ] **Step 5: Commit** + +```bash +git add sqlc.yaml internal/user/infrastructure/persistence +git commit -m "feat(user): migrate user repository from database/sql to sqlc" +``` + +--- + +### Task 6: Migrate AuditLog Domain + +**Files:** +- Modify: `internal/shared/auditlog/queries/queries.sql` — replace placeholder with real queries (JSONB handling) +- Modify: `internal/shared/auditlog/repository.go` — refactor to sqlc + +- [ ] **Step 1: Write real queries to `internal/shared/auditlog/queries/queries.sql`** + +Use `::jsonb` cast on the metadata parameter to avoid importing `github.com/sqlc-dev/pqtype`. Sqlc will generate a `[]byte` field which is compatible with `json.RawMessage`. + +```sql +-- name: InsertAuditLog :exec +INSERT INTO audit_logs (id, request_id, user_id, user_email, method, path, status_code, duration_ms, ip, user_agent, request_body, response_size, created_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13); + +-- name: InsertErrorLog :exec +INSERT INTO error_logs (id, request_id, user_id, user_email, level, message, error, stack_trace, method, path, status_code, ip, user_agent, request_body, metadata, created_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb, $16); +``` + +- [ ] **Step 2: Run `make sqlc` to regenerate** + +```bash +make sqlc +``` + +Expected: Clean exit. Auditlog sqlc package now has insert queries. + +- [ ] **Step 3: Rewrite `internal/shared/auditlog/repository.go`** + +```go +package auditlog + +import ( + "context" + "database/sql" + + "github.com/IDTS-LAB/go-codebase/internal/shared/auditlog/sqlc" +) + +type Repository struct { + db *sql.DB +} + +func NewRepository(db *sql.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) InsertAuditLog(ctx context.Context, log *AuditLog) error { + q := sqlc.New(r.db) + return q.InsertAuditLog(ctx, sqlc.InsertAuditLogParams{ + ID: log.ID, + RequestID: log.RequestID, + UserID: log.UserID, + UserEmail: log.UserEmail, + Method: log.Method, + Path: log.Path, + StatusCode: int32(log.StatusCode), + DurationMs: log.DurationMs, + Ip: log.IP, + UserAgent: log.UserAgent, + RequestBody: log.RequestBody, + ResponseSize: int32(log.ResponseSize), + CreatedAt: log.CreatedAt, + }) +} + +func (r *Repository) InsertErrorLog(ctx context.Context, log *ErrorLog) error { + q := sqlc.New(r.db) + return q.InsertErrorLog(ctx, sqlc.InsertErrorLogParams{ + ID: log.ID, + RequestID: log.RequestID, + UserID: log.UserID, + UserEmail: log.UserEmail, + Level: log.Level, + Message: log.Message, + Error: log.Error, + StackTrace: log.StackTrace, + Method: log.Method, + Path: log.Path, + StatusCode: int32(log.StatusCode), + Ip: log.IP, + UserAgent: log.UserAgent, + RequestBody: log.RequestBody, + Metadata: []byte(log.Metadata), + CreatedAt: log.CreatedAt, + }) +} +``` + +**Note on JSONB handling:** The `::jsonb` cast on `$15` makes sqlc generate the `Metadata` field as `[]byte`. `json.RawMessage` has underlying type `[]byte`, so `[]byte(log.Metadata)` works as a type conversion. The `StatusCode` field needs `int32()` cast from the entity's `int`. Check the generated param struct in `internal/shared/auditlog/sqlc/queries.sql.go` after generation and adjust field names if needed (e.g., `Ip` vs `IP`, `StatusCode` type). + +- [ ] **Step 4: Verify build** + +```bash +go build ./... +``` + +Expected: Clean build. + +- [ ] **Step 5: Remove placeholder query files (optional — harmless to keep)** + +```bash +# No need to remove; the generated Ping method just sits unused but causes no harm. +``` + +- [ ] **Step 6: Commit** + +```bash +git add sqlc.yaml internal/shared/auditlog +git commit -m "feat(auditlog): migrate auditlog repository from database/sql to sqlc with JSONB handling" +``` + +--- + +### Task 7: Final Verification + +**Files:** None + +- [ ] **Step 1: Run `make sqlc` cleanly** + +```bash +make sqlc +``` + +Expected: All 5 sqlc packages regenerate without errors. + +- [ ] **Step 2: Full build** + +```bash +go build ./... +``` + +Expected: Clean build across all packages. + +- [ ] **Step 3: Run tests** + +```bash +go test ./... 2>&1 +``` + +Expected: All existing tests pass. No new tests added; the migration changes only the internal implementation, keeping the same interface contracts and behavior. + +- [ ] **Step 4: Run vet** + +```bash +go vet ./... +``` + +Expected: Clean. + +- [ ] **Step 5: Run lint** + +```bash +make lint +``` + +Expected: Clean or only pre-existing lint issues. + +- [ ] **Step 6: Final verification commit** + +```bash +git add -A +git commit -m "chore: final verification — sqlc generation, build, test, vet, lint all clean" +``` + +--- + +## Verification Summary + +| Check | Expected | +|-------|----------| +| `make sqlc` | 5 packages generated, no errors | +| `go build ./...` | No compilation errors | +| `go test ./...` | All tests pass (no behavioral changes) | +| `go vet ./...` | No vet issues | +| `make lint` | No new lint issues | +| `go.mod` | No new dependencies (pqtype avoided) | diff --git a/docs/superpowers/plans/2026-07-11-unified-response-formatter.md b/docs/superpowers/plans/2026-07-11-unified-response-formatter.md new file mode 100644 index 0000000..78f165a --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-unified-response-formatter.md @@ -0,0 +1,660 @@ +# Unified Response Formatter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build three complementary response-formatting mechanisms (helpers, middleware, adapter) so handlers always produce the unified `APIResponse` envelope with minimal boilerplate. + +**Architecture:** A formatter middleware buffers handler output and normalizes unwrapped responses; `utils.Handle*` helpers provide one-line manual formatting; an `httpadapter` package lets handlers be written as pure `(T, error)` functions. + +**Tech Stack:** Go 1.24, Chi router, standard `encoding/json`, `testify/assert`. + +## Global Constraints + +- All HTTP responses must use the unified `APIResponse` envelope. +- Error mapping stays centralized in `utils.MapError`. +- Middleware must be backward-compatible with existing `Respond*` helpers to avoid double-wrapping. +- New code must include unit tests with table-driven cases. +- Run `go test ./...`, `go vet ./...`, and `go build ./...` before each commit. + +--- + +## File Structure + +- `internal/shared/utils/utils.go` — existing envelope types; add `PaginatedPayload[T]` and `PaginatedResult[T]`. +- `internal/shared/utils/handler.go` — existing `Handle*` helpers; keep and ensure they call `MapError` correctly. +- `internal/shared/middleware/formatter.go` — new formatter middleware with buffered response writer. +- `internal/shared/middleware/formatter_test.go` — new middleware unit tests. +- `internal/shared/httpadapter/adapter.go` — new adapter functions for pure handlers. +- `internal/shared/httpadapter/adapter_test.go` — new adapter unit tests. +- `internal/shared/router/router.go` or equivalent — register formatter middleware. +- Handler files — opportunistically migrate to `utils.Handle*` or `httpadapter.Adapt*`. + +--- + +### Task 1: Add Pagination Types + +**Files:** +- Modify: `internal/shared/utils/utils.go` +- Test: `internal/shared/utils/utils_test.go` (create if missing) + +**Interfaces:** +- Consumes: nothing new. +- Produces: + ```go + type PaginatedPayload[T any] struct { + Data []T `json:"data"` + Pagination PaginationMeta `json:"pagination"` + } + type PaginatedResult[T any] struct { + Data []T + Page int + PerPage int + Total int + } + ``` + +- [ ] **Step 1: Add types to `utils.go`** + + Add the following structs after `ErrorBody`: + + ```go + type PaginatedPayload[T any] struct { + Data []T `json:"data"` + Pagination PaginationMeta `json:"pagination"` + } + + type PaginatedResult[T any] struct { + Data []T + Page int + PerPage int + Total int + } + ``` + +- [ ] **Step 2: Verify build** + + Run: `go build ./internal/shared/utils/...` + Expected: success + +- [ ] **Step 3: Commit** + + ```bash + git add internal/shared/utils/utils.go + git commit -m "chore(utils): add PaginatedPayload and PaginatedResult types" + ``` + +--- + +### Task 2: Implement Response Formatter Middleware + +**Files:** +- Create: `internal/shared/middleware/formatter.go` +- Create: `internal/shared/middleware/formatter_test.go` + +**Interfaces:** +- Consumes: `utils.APIResponse`, `utils.PaginationMeta`, `utils.PaginatedPayload[T]`. +- Produces: + ```go + func ResponseFormatter() func(http.Handler) http.Handler + ``` + +- [ ] **Step 1: Write failing middleware test** + + Create `internal/shared/middleware/formatter_test.go`: + + ```go + package middleware + + import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/IDTS-LAB/go-codebase/internal/shared/utils" + "github.com/stretchr/testify/assert" + ) + + func TestResponseFormatter_WrapsRawSuccessJSON(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"id": "1", "name": "todo"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + ResponseFormatter()(handler).ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp utils.APIResponse + err := json.Unmarshal(rec.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.True(t, resp.Success) + assert.NotNil(t, resp.Data) + assert.Nil(t, resp.Error) + } + + func TestResponseFormatter_PassesThroughExistingEnvelope(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + utils.RespondSuccess(w, map[string]string{"id": "1"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + ResponseFormatter()(handler).ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp utils.APIResponse + err := json.Unmarshal(rec.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.True(t, resp.Success) + assert.NotNil(t, resp.Data) + } + + func TestResponseFormatter_WrapsRawErrorText(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + ResponseFormatter()(handler).ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + + var resp utils.APIResponse + err := json.Unmarshal(rec.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.False(t, resp.Success) + assert.NotNil(t, resp.Error) + assert.Equal(t, "Not Found", resp.Error.Code) + assert.Equal(t, "not found", resp.Error.Message) + } + + func TestResponseFormatter_UnwrapsPaginatedPayload(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + payload := utils.PaginatedPayload[map[string]string]{ + Data: []map[string]string{{"id": "1"}}, + Pagination: utils.PaginationMeta{ + Page: 1, + PerPage: 20, + Total: 1, + TotalPages: 1, + }, + } + json.NewEncoder(w).Encode(payload) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + ResponseFormatter()(handler).ServeHTTP(rec, req) + + var resp utils.APIResponse + err := json.Unmarshal(rec.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.True(t, resp.Success) + assert.NotNil(t, resp.Meta) + assert.Equal(t, 1, resp.Meta.Page) + } + ``` + +- [ ] **Step 2: Run tests to verify they fail** + + Run: `go test ./internal/shared/middleware/... -run TestResponseFormatter -v` + Expected: failures because `ResponseFormatter` does not exist. + +- [ ] **Step 3: Implement formatter middleware** + + Create `internal/shared/middleware/formatter.go`: + + ```go + package middleware + + import ( + "bytes" + "encoding/json" + "net/http" + + "github.com/IDTS-LAB/go-codebase/internal/shared/utils" + ) + + func ResponseFormatter() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fw := &formattingWriter{ResponseWriter: w, statusCode: http.StatusOK} + next.ServeHTTP(fw, r) + + if len(fw.body) == 0 { + w.WriteHeader(fw.statusCode) + return + } + + if isEnvelope(fw.body) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(fw.statusCode) + w.Write(fw.body) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(fw.statusCode) + + if fw.statusCode >= 400 { + var errBody struct { + Code string `json:"code"` + Message string `json:"message"` + } + if json.Unmarshal(fw.body, &errBody) == nil && errBody.Message != "" { + json.NewEncoder(w).Encode(utils.APIResponse{ + Success: false, + Error: &utils.ErrorBody{Code: errBody.Code, Message: errBody.Message}, + }) + return + } + json.NewEncoder(w).Encode(utils.APIResponse{ + Success: false, + Error: &utils.ErrorBody{Code: http.StatusText(fw.statusCode), Message: string(bytes.TrimSpace(fw.body))}, + }) + return + } + + var paginated struct { + Data interface{} `json:"data"` + Pagination interface{} `json:"pagination"` + } + if json.Unmarshal(fw.body, &paginated) == nil && paginated.Data != nil && paginated.Pagination != nil { + var meta utils.PaginationMeta + metaBytes, _ := json.Marshal(paginated.Pagination) + json.Unmarshal(metaBytes, &meta) + json.NewEncoder(w).Encode(utils.APIResponse{ + Success: true, + Data: paginated.Data, + Meta: &meta, + }) + return + } + + var raw interface{} + json.Unmarshal(fw.body, &raw) + json.NewEncoder(w).Encode(utils.APIResponse{ + Success: true, + Data: raw, + Meta: nil, + }) + }) + } + } + + type formattingWriter struct { + http.ResponseWriter + statusCode int + body []byte + wroteHeader bool + } + + func (w *formattingWriter) WriteHeader(code int) { + if w.wroteHeader { + return + } + w.statusCode = code + w.wroteHeader = true + } + + func (w *formattingWriter) Write(b []byte) (int, error) { + w.body = append(w.body, b...) + return len(b), nil + } + + func (w *formattingWriter) Header() http.Header { + return w.ResponseWriter.Header() + } + + func isEnvelope(body []byte) bool { + var envelope struct { + Success *bool `json:"success"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return false + } + return envelope.Success != nil + } + ``` + +- [ ] **Step 4: Run tests to verify they pass** + + Run: `go test ./internal/shared/middleware/... -run TestResponseFormatter -v` + Expected: all tests pass. + +- [ ] **Step 5: Commit** + + ```bash + git add internal/shared/middleware/formatter.go internal/shared/middleware/formatter_test.go + git commit -m "feat(middleware): add response formatter middleware" + ``` + +--- + +### Task 3: Wire Formatter Middleware into Router + +**Files:** +- Modify: router setup file (find where Chi middleware is registered, e.g., `internal/shared/router/router.go` or `cmd/api/main.go`) + +**Interfaces:** +- Consumes: `middleware.ResponseFormatter()`. +- Produces: formatter middleware active on all routes. + +- [ ] **Step 1: Find router setup** + + Run: `grep -R "Use(" --include="*.go" . | grep -i middleware` or search for `chi.NewRouter()`. + Identify the file where middleware is registered. + +- [ ] **Step 2: Register formatter middleware** + + Add `middleware.ResponseFormatter()` near the end of the middleware chain, after auth but before route handlers. Example: + + ```go + r.Use(middleware.RequestID) + r.Use(middleware.Logger(log)) + r.Use(middleware.ErrorHandler(log, errorRepo)) + r.Use(middleware.ErrorRecorder(log, errorRepo)) + r.Use(middleware.Tracer()) + r.Use(middleware.ResponseFormatter()) // add this line + r.Use(middleware.Authentication(tokenSvc)) + ``` + + Exact placement depends on the router file found in Step 1. The formatter should run after recovery/logging/tracing and before auth so auth error responses are also normalized if they bypass helpers. + +- [ ] **Step 3: Verify build** + + Run: `go build ./...` + Expected: success. + +- [ ] **Step 4: Commit** + + ```bash + git add + git commit -m "feat(router): wire response formatter middleware" + ``` + +--- + +### Task 4: Implement Handler Adapter Package + +**Files:** +- Create: `internal/shared/httpadapter/adapter.go` +- Create: `internal/shared/httpadapter/adapter_test.go` + +**Interfaces:** +- Consumes: `utils.Handle`, `utils.HandleCreated`, `utils.HandleNoContent`, `utils.HandlePaginated`, `utils.PaginatedResult[T]`. +- Produces: + ```go + func Adapt[T any](fn func(ctx context.Context, r *http.Request) (T, error)) http.HandlerFunc + func AdaptCreated[T any](fn func(ctx context.Context, r *http.Request) (T, error)) http.HandlerFunc + func AdaptNoContent(fn func(ctx context.Context, r *http.Request) error) http.HandlerFunc + func AdaptPaginated[T any](fn func(ctx context.Context, r *http.Request) (PaginatedResult[T], error)) http.HandlerFunc + ``` + +- [ ] **Step 1: Write failing adapter tests** + + Create `internal/shared/httpadapter/adapter_test.go`: + + ```go + package httpadapter + + import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/utils" + "github.com/stretchr/testify/assert" + ) + + func TestAdapt_ReturnsSuccessEnvelope(t *testing.T) { + handler := Adapt(func(ctx context.Context, r *http.Request) (map[string]string, error) { + return map[string]string{"id": "1"}, nil + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + var resp utils.APIResponse + assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.True(t, resp.Success) + } + + func TestAdaptCreated_Returns201(t *testing.T) { + handler := AdaptCreated(func(ctx context.Context, r *http.Request) (map[string]string, error) { + return map[string]string{"id": "1"}, nil + }) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusCreated, rec.Code) + var resp utils.APIResponse + assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.True(t, resp.Success) + } + + func TestAdaptNoContent_ReturnsSuccessWithNilData(t *testing.T) { + handler := AdaptNoContent(func(ctx context.Context, r *http.Request) error { + return nil + }) + + req := httptest.NewRequest(http.MethodDelete, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + var resp utils.APIResponse + assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.True(t, resp.Success) + assert.Nil(t, resp.Data) + } + + func TestAdaptPaginated_ReturnsPaginationMeta(t *testing.T) { + handler := AdaptPaginated(func(ctx context.Context, r *http.Request) (utils.PaginatedResult[map[string]string], error) { + return utils.PaginatedResult[map[string]string]{ + Data: []map[string]string{{"id": "1"}}, + Page: 1, + PerPage: 20, + Total: 1, + }, nil + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + var resp utils.APIResponse + assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.True(t, resp.Success) + assert.NotNil(t, resp.Meta) + assert.Equal(t, 1, resp.Meta.TotalPages) + } + + func TestAdapt_MapsDomainError(t *testing.T) { + handler := Adapt(func(ctx context.Context, r *http.Request) (map[string]string, error) { + return nil, domain.ErrNotFound + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + var resp utils.APIResponse + assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.False(t, resp.Success) + assert.Equal(t, "NOT_FOUND", resp.Error.Code) + } + ``` + +- [ ] **Step 2: Run tests to verify they fail** + + Run: `go test ./internal/shared/httpadapter/... -v` + Expected: failures because package does not exist. + +- [ ] **Step 3: Implement adapter package** + + Create `internal/shared/httpadapter/adapter.go`: + + ```go + package httpadapter + + import ( + "context" + "net/http" + + "github.com/IDTS-LAB/go-codebase/internal/shared/utils" + ) + + func Adapt[T any](fn func(ctx context.Context, r *http.Request) (T, error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + data, err := fn(r.Context(), r) + utils.Handle(w, data, err) + } + } + + func AdaptCreated[T any](fn func(ctx context.Context, r *http.Request) (T, error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + data, err := fn(r.Context(), r) + utils.HandleCreated(w, data, err) + } + } + + func AdaptNoContent(fn func(ctx context.Context, r *http.Request) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + err := fn(r.Context(), r) + utils.HandleNoContent(w, err) + } + } + + func AdaptPaginated[T any](fn func(ctx context.Context, r *http.Request) (utils.PaginatedResult[T], error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + result, err := fn(r.Context(), r) + utils.HandlePaginated(w, result.Data, result.Page, result.PerPage, result.Total, err) + } + } + ``` + +- [ ] **Step 4: Run tests to verify they pass** + + Run: `go test ./internal/shared/httpadapter/... -v` + Expected: all tests pass. + +- [ ] **Step 5: Commit** + + ```bash + git add internal/shared/httpadapter/ + git commit -m "feat(httpadapter): add pure-function handler adapters" + ``` + +--- + +### Task 5: Refactor Existing Handlers to Use Helpers or Adapter + +**Files:** +- Modify: `internal/authentication/interfaces/http/handlers.go` +- Modify: `internal/todo/interfaces/http/handlers.go` +- Modify: `internal/authorization/interfaces/http/handlers.go` +- Modify: `internal/user/interfaces/http/handler.go` + +**Interfaces:** +- Consumes: `utils.Handle`, `utils.HandleCreated`, `utils.HandleNoContent`, `utils.HandlePaginated`. +- Produces: shorter, consistent handlers that produce the same envelope. + +- [ ] **Step 1: Refactor authentication handlers** + + Replace patterns like: + ```go + user, err := h.svc.Register(...) + if err != nil { + utils.MapError(w, err) + return + } + utils.RespondCreated(w, user) + ``` + with: + ```go + user, err := h.svc.Register(...) + utils.HandleCreated(w, user, err) + ``` + + Do this for all endpoints in `internal/authentication/interfaces/http/handlers.go` where it does not change behavior. Keep custom branches (e.g., `ErrInvalidVerifyToken` → `RespondBadRequest`) if they provide more specific messages than `MapError`. + +- [ ] **Step 2: Refactor todo handlers** + + Apply the same replacement pattern in `internal/todo/interfaces/http/handlers.go`. + +- [ ] **Step 3: Refactor authorization handlers** + + Apply the same replacement pattern in `internal/authorization/interfaces/http/handlers.go`. + +- [ ] **Step 4: Refactor user handlers** + + Apply the same replacement pattern in `internal/user/interfaces/http/handler.go`. + +- [ ] **Step 5: Run tests and fix failures** + + Run: `go test ./...` + Expected: all tests pass. Fix any test that asserts on exact response structure if the refactor changes it. + +- [ ] **Step 6: Commit** + + ```bash + git add internal/authentication/interfaces/http/handlers.go \ + internal/todo/interfaces/http/handlers.go \ + internal/authorization/interfaces/http/handlers.go \ + internal/user/interfaces/http/handler.go + git commit -m "refactor(handlers): use utils.Handle* helpers" + ``` + +--- + +### Task 6: Regenerate Swagger and Run Final Verification + +**Files:** +- Modify: `docs/swagger.json`, `docs/swagger.yaml` (if generated) +- Test: entire suite + +- [ ] **Step 1: Regenerate Swagger docs** + + Run: `make swagger` (or `swag init -g cmd/api/main.go`, whichever is configured). + +- [ ] **Step 2: Run full verification** + + Run: + ```bash + go build ./... + go vet ./... + go test ./... + ``` + Expected: all pass. + +- [ ] **Step 3: Commit** + + ```bash + git add docs/ + git commit -m "docs(swagger): regenerate after response formatter refactor" + ``` + +--- + +## Self-Review Checklist + +- [ ] Spec coverage: helpers, middleware, adapter, router wiring, handler refactor, tests, swagger regeneration are all represented. +- [ ] No placeholders: every step includes exact code or exact commands. +- [ ] Type consistency: `PaginatedPayload`, `PaginatedResult`, `APIResponse`, `ErrorBody`, `PaginationMeta` match across tasks. +- [ ] Middleware ordering: formatter placed after recovery/logging/tracing and before auth so error responses are normalized. +- [ ] Backward compatibility: formatter detects existing envelopes and passes them through without double-wrapping. diff --git a/docs/superpowers/plans/2026-07-12-cqrs-standardization.md b/docs/superpowers/plans/2026-07-12-cqrs-standardization.md new file mode 100644 index 0000000..2c760f5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-cqrs-standardization.md @@ -0,0 +1,869 @@ +# CQRS Standardization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add CommandBus/QueryBus infrastructure and standardize all 5 domain modules (auth, user, authorization, tenant, todo) to use CQRS with central dispatch. + +**Architecture:** Shared `CommandBus` and `QueryBus` in `internal/shared/cqrs/` route commands/queries by type to registered handlers. Each module's HTTP handlers dispatch to the bus instead of calling services directly. Event publishing moves into command handlers. + +**Tech Stack:** Go, fx, sqlc, in-memory bus with reflect-based routing + +## Global Constraints + +- All bus routing uses `reflect.TypeOf(cmd/query).String()` as map key +- Command/Query handlers follow the exact same pattern: one file per operation, `Handle(ctx, struct) (response, error)` +- No new event types or EventBus changes +- Existing repository interfaces and domain entities remain unchanged +- Tests must pass after each task + +--- + +### Task 1: Create Shared CQRS Bus Layer + +**Files:** +- Create: `internal/shared/cqrs/bus.go` +- Create: `internal/shared/cqrs/bus_test.go` +- Create: `internal/shared/cqrs/module.go` + +**Interfaces:** +- Produces: `CommandBus`, `QueryBus`, `CommandHandler`, `QueryHandler` interfaces +- Produces: `InMemoryCommandBus`, `InMemoryQueryBus` implementations +- Produces: Fx module providing both buses as singletons + +- [ ] **Step 1: Create `internal/shared/cqrs/bus.go`** + +```go +package cqrs + +import ( + "context" + "fmt" + "reflect" + "sync" +) + +type CommandHandler interface { + Handle(ctx context.Context, cmd any) (any, error) +} + +type CommandBus interface { + Dispatch(ctx context.Context, cmd any) (any, error) + Register(cmd any, handler CommandHandler) +} + +type QueryHandler interface { + Handle(ctx context.Context, query any) (any, error) +} + +type QueryBus interface { + Ask(ctx context.Context, query any) (any, error) + Register(query any, handler QueryHandler) +} + +type inMemoryCommandBus struct { + mu sync.RWMutex + handlers map[string]CommandHandler +} + +func NewInMemoryCommandBus() *inMemoryCommandBus { + return &inMemoryCommandBus{handlers: make(map[string]CommandHandler)} +} + +func (b *inMemoryCommandBus) Register(cmd any, handler CommandHandler) { + key := reflect.TypeOf(cmd).String() + b.mu.Lock() + defer b.mu.Unlock() + b.handlers[key] = handler +} + +func (b *inMemoryCommandBus) Dispatch(ctx context.Context, cmd any) (any, error) { + key := reflect.TypeOf(cmd).String() + b.mu.RLock() + handler, ok := b.handlers[key] + b.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("no handler registered for command: %s", key) + } + return handler.Handle(ctx, cmd) +} + +type inMemoryQueryBus struct { + mu sync.RWMutex + handlers map[string]QueryHandler +} + +func NewInMemoryQueryBus() *inMemoryQueryBus { + return &inMemoryQueryBus{handlers: make(map[string]QueryHandler)} +} + +func (b *inMemoryQueryBus) Register(query any, handler QueryHandler) { + key := reflect.TypeOf(query).String() + b.mu.Lock() + defer b.mu.Unlock() + b.handlers[key] = handler +} + +func (b *inMemoryQueryBus) Ask(ctx context.Context, query any) (any, error) { + key := reflect.TypeOf(query).String() + b.mu.RLock() + handler, ok := b.handlers[key] + b.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("no handler registered for query: %s", key) + } + return handler.Handle(ctx, query) +} +``` + +- [ ] **Step 2: Create `internal/shared/cqrs/bus_test.go`** + +```go +package cqrs + +import ( + "context" + "errors" + "testing" +) + +type testCommand struct { + Value string +} + +type testCommandHandler struct{} + +func (h *testCommandHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(testCommand) + if c.Value == "error" { + return nil, errors.New("test error") + } + return "handled:" + c.Value, nil +} + +type testQuery struct { + ID string +} + +type testQueryHandler struct{} + +func (h *testQueryHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(testQuery) + return "result:" + q.ID, nil +} + +func TestCommandBus_Dispatch(t *testing.T) { + bus := NewInMemoryCommandBus() + bus.Register(testCommand{}, &testCommandHandler{}) + + resp, err := bus.Dispatch(context.Background(), testCommand{Value: "hello"}) + if err != nil { + t.Fatal(err) + } + if resp.(string) != "handled:hello" { + t.Fatalf("expected 'handled:hello', got '%s'", resp) + } +} + +func TestCommandBus_Unregistered(t *testing.T) { + bus := NewInMemoryCommandBus() + _, err := bus.Dispatch(context.Background(), testCommand{Value: "x"}) + if err == nil { + t.Fatal("expected error for unregistered command") + } +} + +func TestCommandBus_HandlerError(t *testing.T) { + bus := NewInMemoryCommandBus() + bus.Register(testCommand{}, &testCommandHandler{}) + + _, err := bus.Dispatch(context.Background(), testCommand{Value: "error"}) + if err == nil || err.Error() != "test error" { + t.Fatalf("expected 'test error', got '%v'", err) + } +} + +func TestQueryBus_Ask(t *testing.T) { + bus := NewInMemoryQueryBus() + bus.Register(testQuery{}, &testQueryHandler{}) + + resp, err := bus.Ask(context.Background(), testQuery{ID: "123"}) + if err != nil { + t.Fatal(err) + } + if resp.(string) != "result:123" { + t.Fatalf("expected 'result:123', got '%s'", resp) + } +} + +func TestQueryBus_Unregistered(t *testing.T) { + bus := NewInMemoryQueryBus() + _, err := bus.Ask(context.Background(), testQuery{ID: "x"}) + if err == nil { + t.Fatal("expected error for unregistered query") + } +} +``` + +- [ ] **Step 3: Create `internal/shared/cqrs/module.go`** + +```go +package cqrs + +import "go.uber.org/fx" + +var Module = fx.Module("cqrs", + fx.Provide( + NewInMemoryCommandBus, + NewInMemoryQueryBus, + ), +) +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/shared/cqrs/... -v` +Expected: all 5 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/shared/cqrs/ && git commit -m "feat(cqrs): add CommandBus and QueryBus with in-memory implementations" +``` + +--- + +### Task 2: Migrate Authorization Module to CQRS + +**Files:** +- Create: `internal/authorization/application/command/create_role.go` +- Create: `internal/authorization/application/command/update_role.go` +- Create: `internal/authorization/application/command/delete_role.go` +- Create: `internal/authorization/application/command/create_permission.go` +- Create: `internal/authorization/application/command/update_permission.go` +- Create: `internal/authorization/application/command/delete_permission.go` +- Create: `internal/authorization/application/command/assign_role.go` +- Create: `internal/authorization/application/command/unassign_role.go` +- Create: `internal/authorization/application/command/assign_permission.go` +- Create: `internal/authorization/application/command/unassign_permission.go` +- Create: `internal/authorization/application/query/get_role.go` +- Create: `internal/authorization/application/query/list_roles.go` +- Create: `internal/authorization/application/query/get_permission.go` +- Create: `internal/authorization/application/query/list_permissions.go` +- Create: `internal/authorization/application/query/get_user_roles.go` +- Create: `internal/authorization/application/query/get_role_permissions.go` +- Create: `internal/authorization/application/query/check_permission.go` +- Modify: `internal/authorization/application/service/authorization_service.go` — delete file entirely (replaced by handlers) +- Modify: `internal/authorization/module.go` — register handlers with bus, remove service provider +- Modify: `internal/authorization/interfaces/http/handlers.go` — use bus instead of service +- Modify: `internal/authorization/interfaces/http/handler_test.go` — update constructor call + +**Interfaces:** +- Consumes: `CommandBus`, `QueryBus` from cqrs package +- Consumes: `repository.RoleRepository`, `repository.PermissionRepository`, `repository.UserRoleRepository`, `repository.RolePermissionRepository`, `casbin.Enforcer` +- Produces: command/query handler types registered with bus + +Each command/query file follows this pattern: + +```go +// create_role.go +package command + +import ( + "context" + "errors" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + coredomain "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/google/uuid" +) + +type CreateRoleCommand struct { + Name string + Description string +} + +type CreateRoleHandler struct { + roleRepo repository.RoleRepository + enforcer Enforcer +} + +type Enforcer interface { + ReloadPolicies(ctx context.Context) error +} + +func NewCreateRoleHandler(roleRepo repository.RoleRepository, enforcer Enforcer) *CreateRoleHandler { + return &CreateRoleHandler{roleRepo: roleRepo, enforcer: enforcer} +} + +func (h *CreateRoleHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(CreateRoleCommand) + existing, _ := h.roleRepo.GetByName(ctx, c.Name) + if existing != nil { + return nil, coredomain.ErrConflict + } + role := entity.NewRole(c.Name, c.Description) + if err := h.roleRepo.Create(ctx, role); err != nil { + return nil, err + } + return role, nil +} +``` + +```go +// get_role.go +package query + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/google/uuid" +) + +type GetRoleQuery struct { + ID uuid.UUID +} + +type GetRoleHandler struct { + roleRepo repository.RoleRepository +} + +func NewGetRoleHandler(roleRepo repository.RoleRepository) *GetRoleHandler { + return &GetRoleHandler{roleRepo: roleRepo} +} + +func (h *GetRoleHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(GetRoleQuery) + return h.roleRepo.GetByID(ctx, q.ID) +} +``` + +- [ ] **Step 1: Create all 17 command/query files** + +Each file follows the pattern above with: +- Unique command/query struct +- Handler struct with repository/enforcer dependencies +- Constructor function +- `Handle(ctx, any) (any, error)` method + +Create commands: +1. `create_role.go` — creates role, checks existing name, returns `*entity.Role` +2. `update_role.go` — gets role by ID, updates fields, calls repo.Update, returns `*entity.Role` +3. `delete_role.go` — calls repo.Delete +4. `create_permission.go` — same pattern as create_role but for Permission +5. `update_permission.go` — same pattern as update_role but for Permission +6. `delete_permission.go` — calls repo.Delete +7. `assign_role.go` — calls repo.GetByID for role validation, then userRoleRepo.Assign, then enforcer.ReloadUserPolicies +8. `unassign_role.go` — calls userRoleRepo.Remove, then enforcer.ReloadUserPolicies +9. `assign_permission.go` — calls roleRepo.GetByID + permRepo.GetByID for validation, then rolePermRepo.Assign, then enforcer.ReloadPolicies +10. `unassign_permission.go` — calls rolePermRepo.Remove, then enforcer.ReloadPolicies + +Create queries: +11. `get_role.go` — calls roleRepo.GetByID, returns `*entity.Role` +12. `list_roles.go` — calls roleRepo.GetAll, returns `([]*entity.Role, int)` +13. `get_permission.go` — calls permRepo.GetByID, returns `*entity.Permission` +14. `list_permissions.go` — calls permRepo.GetAll, returns `([]*entity.Permission, int)` +15. `get_user_roles.go` — calls userRoleRepo.GetRolesByUserID, returns `[]*entity.Role` +16. `get_role_permissions.go` — calls rolePermRepo.GetPermissionsByRoleID, returns `[]*entity.Permission` +17. `check_permission.go` — calls enforcer.Enforce, returns `bool` + +- [ ] **Step 2: Update `internal/authorization/interfaces/http/handlers.go`** + +Add `cqrs.CommandBus` and `cqrs.QueryBus` to constructor and all handler methods. + +Current pattern: +```go +type Handler struct { + svc *service.AuthorizationService +} +``` + +New pattern: +```go +type Handler struct { + commandBus cqrs.CommandBus + queryBus cqrs.QueryBus +} +``` + +Each handler method changes from `h.svc.CreateRole(...)` to dispatching a command/query: +```go +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + // ... parse request ... + resp, err := h.commandBus.Dispatch(r.Context(), command.CreateRoleCommand{ + Name: req.Name, + Description: req.Description, + }) + // ... handle response ... +} +``` + +For methods that return entity types, cast to the expected type: +```go +role := resp.(*entity.Role) +// map to DTO response... +``` + +Remove dependency on `service.AuthorizationService`. Commands import from `command` package, queries from `query` package. + +- [ ] **Step 3: Update `internal/authorization/module.go`** + +Remove `fx.Provide` for `service.NewAuthorizationService`. Add `fx.Invoke` to register handlers with the bus: + +```go +package authorization + +import ( + "github.com/IDTS-LAB/go-codebase/internal/authorization/application/command" + "github.com/IDTS-LAB/go-codebase/internal/authorization/application/query" + "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/casbin" + "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/persistence" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/shared/tenantfilter" + "go.uber.org/fx" +) + +var Module = fx.Module("authorization", + fx.Provide( + persistence.NewRoleRepository, + persistence.NewPermissionRepository, + persistence.NewUserRoleRepository, + persistence.NewRolePermissionRepository, + ), + fx.Provide(NewHandler), + fx.Invoke(registerHandlers), +) + +func registerHandlers( + commandBus cqrs.CommandBus, + queryBus cqrs.QueryBus, + roleRepo persistence.RoleRepository, + permRepo persistence.PermissionRepository, + userRoleRepo persistence.UserRoleRepository, + rolePermRepo persistence.RolePermissionRepository, + enforcer *casbin.Enforcer, +) { + // Commands + commandBus.Register(command.CreateRoleCommand{}, command.NewCreateRoleHandler(roleRepo, enforcer)) + commandBus.Register(command.UpdateRoleCommand{}, command.NewUpdateRoleHandler(roleRepo)) + commandBus.Register(command.DeleteRoleCommand{}, command.NewDeleteRoleHandler(roleRepo)) + commandBus.Register(command.CreatePermissionCommand{}, command.NewCreatePermissionHandler(permRepo)) + commandBus.Register(command.UpdatePermissionCommand{}, command.NewUpdatePermissionHandler(permRepo)) + commandBus.Register(command.DeletePermissionCommand{}, command.NewDeletePermissionHandler(permRepo)) + commandBus.Register(command.AssignRoleCommand{}, command.NewAssignRoleHandler(roleRepo, userRoleRepo, enforcer)) + commandBus.Register(command.UnassignRoleCommand{}, command.NewUnassignRoleHandler(userRoleRepo, enforcer)) + commandBus.Register(command.AssignPermissionCommand{}, command.NewAssignPermissionHandler(roleRepo, permRepo, rolePermRepo, enforcer)) + commandBus.Register(command.UnassignPermissionCommand{}, command.NewUnassignPermissionHandler(rolePermRepo, enforcer)) + + // Queries + queryBus.Register(query.GetRoleQuery{}, query.NewGetRoleHandler(roleRepo)) + queryBus.Register(query.ListRolesQuery{}, query.NewListRolesHandler(roleRepo)) + queryBus.Register(query.GetPermissionQuery{}, query.NewGetPermissionHandler(permRepo)) + queryBus.Register(query.ListPermissionsQuery{}, query.NewListPermissionsHandler(permRepo)) + queryBus.Register(query.GetUserRolesQuery{}, query.NewGetUserRolesHandler(userRoleRepo)) + queryBus.Register(query.GetRolePermissionsQuery{}, query.NewGetRolePermissionsHandler(rolePermRepo)) + queryBus.Register(query.CheckPermissionQuery{}, query.NewCheckPermissionHandler(enforcer)) +} +``` + +Note: `persistence.RoleRepository` is the concrete type from persistence package, but `registerHandlers` needs the interface type `repository.RoleRepository`. Since Go uses implicit interface satisfaction, use the concrete type in fx and cast in the function: + +```go +type RoleRepository interface { + Create(ctx context.Context, role *entity.Role) error + // ... etc +} +``` + +Actually, since the handler constructors accept interface types, we need to use the interface. But since Fx resolves by type, we need to either provide the interface or use the concrete type and cast. + +The cleanest approach: define aliases for the repository interfaces at the module level, or provide the interfaces directly. Let's keep it simple — the handler constructors accept the repository interfaces directly, and Fx will resolve them since the concrete types satisfy the interfaces: + +```go +func registerHandlers( + commandBus cqrs.CommandBus, + queryBus cqrs.QueryBus, + roleRepo repository.RoleRepository, + permRepo repository.PermissionRepository, + userRoleRepo repository.UserRoleRepository, + rolePermRepo repository.RolePermissionRepository, + enforcer Enforcer, +) { +``` + +- [ ] **Step 4: Update the Handler struct to inject commandBus/queryBus** + +Read the current `internal/authorization/interfaces/http/handlers.go` and update the constructor and all methods. + +The Handler currently: +```go +type Handler struct { + svc *service.AuthorizationService +} + +func NewHandler(svc *service.AuthorizationService, v *validator.Validator) *Handler { +``` + +Update to: +```go +type Handler struct { + commandBus cqrs.CommandBus + queryBus cqrs.QueryBus + v *validator.Validator +} + +func NewHandler(commandBus cqrs.CommandBus, queryBus cqrs.QueryBus, v *validator.Validator) *Handler { +``` + +Update module.go's `fx.Provide(NewHandler)` — Fx auto-resolves the new dependencies. + +- [ ] **Step 5: Update `internal/authorization/interfaces/http/handler_test.go`** + +The test creates a Handler with `handlers.NewHandler(svc, validator)`. Update to: +```go +cmdBus := cqrs.NewInMemoryCommandBus() +qBus := cqrs.NewInMemoryQueryBus() +h := handlers.NewHandler(cmdBus, qBus, validator) + +// Register mock handlers for tests +cmdBus.Register(command.CreateRoleCommand{}, ...) +``` + +Since the tests need to register handlers that return mock data, create a simple test handler that returns predefined values: + +```go +type mockCreateRoleHandler struct { + result *entity.Role + err error +} + +func (h *mockCreateRoleHandler) Handle(ctx context.Context, cmd any) (any, error) { + return h.result, h.err +} +``` + +Register these in test setup. + +- [ ] **Step 6: Run tests** + +Run: `go test ./internal/authorization/... -v -count=1` +Expected: all tests PASS (previously 57 tests) + +- [ ] **Step 7: Commit** + +```bash +git add internal/authorization/ && git commit -m "refactor(authorization): migrate to CQRS with CommandBus/QueryBus" +``` + +--- + +### Task 3: Migrate User Module to CQRS + +**Files:** +- Create: `internal/user/application/command/update_user.go` +- Create: `internal/user/application/command/delete_user.go` +- Create: `internal/user/application/query/list_users.go` +- Create: `internal/user/application/query/get_user.go` +- Delete: `internal/user/application/service/user_service.go` +- Delete: `internal/user/application/service/service_test.go` +- Modify: `internal/user/interfaces/http/handler.go` — use bus instead of service +- Modify: `internal/user/interfaces/http/handler_test.go` — update constructor +- Modify: `internal/user/module.go` — register handlers with bus + +- [ ] **Step 1: Create 4 command/query files** + +Commands: +1. `update_user.go` — `UpdateUserCommand{ID uuid.UUID, Name string, Email string, IsActive bool}` — handler updates user via repository +2. `delete_user.go` — `DeleteUserCommand{ID uuid.UUID}` — handler soft-deletes user + +Queries: +3. `list_users.go` — `ListUsersQuery{Offset, Limit int}` — returns `([]*entity.User, int)` +4. `get_user.go` — `GetUserQuery{ID uuid.UUID}` — returns `*entity.User` + +Each handler depends on `repository.UserRepository` (from `internal/user/domain/repository`). + +- [ ] **Step 2: Update `internal/user/interfaces/http/handler.go`** + +Replace `*service.UserService` with `cqrs.CommandBus` + `cqrs.QueryBus`. Update all handler methods. + +- [ ] **Step 3: Update `internal/user/module.go`** + +Remove `fx.Provide(service.NewUserService)`. Add `fx.Invoke(registerHandlers)`. + +Handler registration: +```go +commandBus.Register(command.UpdateUserCommand{}, command.NewUpdateUserHandler(userRepo)) +commandBus.Register(command.DeleteUserCommand{}, command.NewDeleteUserHandler(userRepo)) +queryBus.Register(query.ListUsersQuery{}, query.NewListUsersHandler(userRepo)) +queryBus.Register(query.GetUserQuery{}, query.NewGetUserHandler(userRepo)) +``` + +- [ ] **Step 4: Update `internal/user/interfaces/http/handler_test.go`** + +Replace `service.NewUserService` with bus-based constructor using mock handlers. + +- [ ] **Step 5: Run tests** + +Run: `go test ./internal/user/... -v -count=1` +Expected: all 26 tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/user/ && git commit -m "refactor(user): migrate to CQRS with CommandBus/QueryBus" +``` + +--- + +### Task 4: Migrate Tenant Module to CQRS + +**Files:** +- Create: `internal/tenant/application/command/create_tenant.go` +- Create: `internal/tenant/application/command/update_tenant.go` +- Create: `internal/tenant/application/command/delete_tenant.go` +- Create: `internal/tenant/application/query/get_tenant.go` +- Create: `internal/tenant/application/query/list_tenants.go` +- Delete: `internal/tenant/application/service/tenant.go` +- Modify: `internal/tenant/interfaces/http/handlers.go` — use bus instead of service +- Modify: `internal/tenant/module.go` — register handlers with bus + +- [ ] **Step 1: Create 5 command/query files** + +Commands: +1. `create_tenant.go` — `CreateTenantCommand{dto.CreateTenantRequest}` — creates tenant via repository, returns `dto.TenantResponse` +2. `update_tenant.go` — `UpdateTenantCommand{ID, Name, Domain, Settings, IsActive}` — updates via repository +3. `delete_tenant.go` — `DeleteTenantCommand{ID}` — deletes via repository + +Queries: +4. `get_tenant.go` — `GetTenantQuery{ID}` — returns `dto.TenantResponse` +5. `list_tenants.go` — `ListTenantsQuery{Page, PerPage}` — returns `dto.TenantListResponse` + +- [ ] **Step 2: Update `internal/tenant/interfaces/http/handlers.go`** + +Replace `*appService.TenantService` with `cqrs.CommandBus` + `cqrs.QueryBus`. Update all handler methods. + +- [ ] **Step 3: Update `internal/tenant/module.go`** + +Remove service provider. Add handler registration via `fx.Invoke`. + +- [ ] **Step 4: Run tests** + +Run: `go build ./internal/tenant/...` +Expected: builds clean + +- [ ] **Step 5: Commit** + +```bash +git add internal/tenant/ && git commit -m "refactor(tenant): migrate to CQRS with CommandBus/QueryBus" +``` + +--- + +### Task 5: Migrate Authentication Module to CQRS + +**Files:** +- Create: `internal/authentication/application/command/register_user.go` +- Create: `internal/authentication/application/command/generate_tokens.go` +- Create: `internal/authentication/application/command/refresh_token.go` +- Create: `internal/authentication/application/command/logout.go` +- Create: `internal/authentication/application/command/logout_all.go` +- Create: `internal/authentication/application/command/verify_email.go` +- Create: `internal/authentication/application/command/forgot_password.go` +- Create: `internal/authentication/application/command/reset_password.go` +- Create: `internal/authentication/application/command/resend_verification.go` +- Create: `internal/authentication/application/query/login.go` +- Modify: `internal/authentication/module.go` — register handlers, remove service +- Modify: `internal/authentication/interfaces/http/handlers.go` — use bus instead of service +- Modify: `internal/authentication/application/service/authentication_service.go` — delete (replaced by handlers) +- Modify: `internal/authentication/application/service/authentication_service_test.go` — delete (move tests or rewrite) + +- [ ] **Step 1: Create 10 command/query files** + +Commands (9): +1. `register_user.go` — hashes password, creates user, generates verify token, publishes `UserRegisteredEvent` +2. `generate_tokens.go` — generates JWT + refresh token pair, returns `service.TokenPair` +3. `refresh_token.go` — validates refresh token, generates new pair +4. `logout.go` — revokes refresh token + optionally denylists JWT +5. `logout_all.go` — revokes all refresh tokens for user +6. `verify_email.go` — validates verification token, marks email verified, publishes `EmailVerifiedEvent` +7. `forgot_password.go` — generates reset token, publishes `PasswordResetRequestedEvent` +8. `reset_password.go` — validates reset token, updates password, revokes all sessions +9. `resend_verification.go` — generates new verify token, publishes `UserRegisteredEvent` + +Queries (1): +10. `login.go` — authenticates user, checks lockout/verification, returns `*entity.User` + +Handler dependencies: +- `repository.UserRepository` +- `repository.RefreshTokenRepository` +- `domain.TokenService` +- `events.EventBus` +- Optional: `denylist func(ctx, jti string, ttl time.Duration) error` + +- [ ] **Step 2: Update `internal/authentication/interfaces/http/handlers.go`** + +Replace `*service.AuthenticationService` with `cqrs.CommandBus` + `cqrs.QueryBus`. + +Handlers like `Register`: +```go +func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { + var req dto.RegisterRequest + // ... + resp, err := h.commandBus.Dispatch(r.Context(), command.RegisterUserCommand{ + Email: req.Email, + Password: req.Password, + Name: req.Name, + }) + // ... handle response ... +} +``` + +- [ ] **Step 3: Update module.go** + +Register all 10 handlers with the bus via `fx.Invoke`. + +- [ ] **Step 4: Update tests** + +The existing `authentication_service_test.go` tests the service methods directly. Rewrite these as command/query handler tests. Use the same mocked repositories approach. + +For the HTTP handler tests (`authentication/interfaces/http/handlers_test.go`), update to use bus with mock handlers. + +- [ ] **Step 5: Run tests** + +Run: `go test ./internal/authentication/... -v -count=1` +Expected: all tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add internal/authentication/ && git commit -m "refactor(authentication): migrate to CQRS with CommandBus/QueryBus" +``` + +--- + +### Task 6: Migrate Todo Module to Use Bus + +**Files:** +- Delete: `internal/todo/application/service/todo_app_service.go` +- Modify: `internal/todo/interfaces/http/handlers.go` — use bus instead of service facade +- Modify: `internal/todo/module.go` — register handlers with bus, remove service provider +- Modify: `internal/todo/interfaces/http/handlers_test.go` — update constructor + +- [ ] **Step 1: Update `internal/todo/interfaces/http/handlers.go`** + +Replace `*appService.TodoAppService` with `cqrs.CommandBus` + `cqrs.QueryBus`. Wire each method to dispatch the corresponding command/query: + +```go +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + var req dto.CreateTodoRequest + // ... parse ... + resp, err := h.commandBus.Dispatch(r.Context(), command.CreateTodoCommand{ + Title: req.Title, + Description: req.Description, + }) + // ... handle ... +} +``` + +- [ ] **Step 2: Update `internal/todo/module.go`** + +Remove `fx.Provide(appService.NewTodoAppService)`. Add `fx.Invoke` to register command/query handlers: + +```go +func registerHandlers( + commandBus cqrs.CommandBus, + queryBus cqrs.QueryBus, + createHandler *command.CreateTodoHandler, + updateHandler *command.UpdateTodoHandler, + completeHandler *command.CompleteTodoHandler, + deleteHandler *command.DeleteTodoHandler, + getHandler *query.GetTodoHandler, + listHandler *query.ListTodosHandler, + searchHandler *query.SearchTodosHandler, +) { + commandBus.Register(command.CreateTodoCommand{}, createHandler) + commandBus.Register(command.UpdateTodoCommand{}, updateHandler) + commandBus.Register(command.CompleteTodoCommand{}, completeHandler) + commandBus.Register(command.DeleteTodoCommand{}, deleteHandler) + queryBus.Register(query.GetTodoQuery{}, getHandler) + queryBus.Register(query.ListTodosQuery{}, listHandler) + queryBus.Register(query.SearchTodosQuery{}, searchHandler) +} +``` + +- [ ] **Step 3: Update test** + +Update handler tests to use bus with registered mock handlers. + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/todo/... -v -count=1` +Expected: all tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/todo/ && git commit -m "refactor(todo): migrate AppService facade to direct bus dispatch" +``` + +--- + +### Task 7: Update Main Wiring + +**Files:** +- Modify: `cmd/api/main.go` + +- [ ] **Step 1: Add cqrs module to fx.New** + +```go +app := fx.New( + // ... existing ... + cqrs.Module, // Add before domain modules + // ... domain modules ... +) +``` + +- [ ] **Step 2: Update handler constructors** + +The handler constructors changed — they now accept `CommandBus` and `QueryBus` instead of service instances. Fx auto-resolves these, so the `fx.Populate` and router wiring stay the same if the constructors are updated. + +The router `NewRouter` calls need their handler constructors updated to match new signatures. Since Fx handles injection, the main.go `fx.Populate` should still work as long as the new constructors are correctly set up. + +- [ ] **Step 3: Run tests** + +Run: `go build ./... && go test ./... -count=1` +Expected: all builds and tests PASS + +- [ ] **Step 4: Commit** + +```bash +git add cmd/api/main.go && git commit -m "feat: wire CQRS CommandBus/QueryBus into application" +``` + +--- + +### Task 8: Remove Dead Code + +- [ ] **Step 1: Remove deleted service files from git** + +Some service files were deleted in previous tasks but may still be in the working tree. Clean up: + +```bash +git rm internal/user/application/service/user_service.go internal/user/application/service/service_test.go 2>/dev/null || true +git rm internal/authorization/application/service/authorization_service.go 2>/dev/null || true +git rm internal/tenant/application/service/tenant.go 2>/dev/null || true +git rm internal/authentication/application/service/authentication_service.go 2>/dev/null || true +git rm internal/todo/application/service/todo_app_service.go 2>/dev/null || true +``` + +- [ ] **Step 2: Final test pass** + +Run: `go build ./... && go test ./... -count=1` +Expected: clean build, all tests pass + +- [ ] **Step 3: Commit** + +```bash +git add -A && git commit -m "chore: remove deprecated service facades after CQRS migration" +``` diff --git a/docs/superpowers/plans/2026-07-12-multitenancy.md b/docs/superpowers/plans/2026-07-12-multitenancy.md new file mode 100644 index 0000000..f22377d --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-multitenancy.md @@ -0,0 +1,834 @@ +# Multi-Tenancy + User Normalization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add toggleable row-level multi-tenancy (tenant_id), normalized user tables (10 purpose-specific tables), and a tenant management module. + +**Architecture:** Row-level isolation via `tenant_id` column on all domain tables. Tenant resolved from JWT → header → subdomain pipeline. User tables split from one monolithic `users` table into purpose-specific tables by module boundary. + +**Tech Stack:** Go 1.25, Chi router, Uber Fx, sqlc, PostgreSQL 16, testify. + +## Global Constraints + +- Multi-tenancy must be toggleable via `multitenancy.enabled` config; when `false`, existing behavior is unchanged. +- All domain entities get `TenantID string` field. +- New user tables must include a data migration from the old `users` table. +- Each migration must be reversible (goose Up/Down). +- Run `go build ./... && go vet ./... && go test ./...` before each commit. + +--- + +## File Structure + +``` +migrations/ + 007_create_tenants.sql — new + 008_normalize_users.sql — new + 009_add_tenant_id.sql — new +internal/ + shared/ + config/config.go — modify: add TenantConfig + middleware/tenant.go — new: tenant context + middleware + middleware/middleware.go — modify: add TenantIDKey + router/router.go — modify: wire TenantResolver + core/domain/ + token.go — modify: add TenantID field + infrastructure/auth/jwt.go — modify: add TenantID to claims + authentication/ — modify: split to new tables + domain/entity/user.go — modify: remove old fields + domain/event/events.go — modify + application/service/... — modify: use new tables + infrastructure/persistence/... — modify: sqlc + repos + interfaces/http/handlers.go — modify + user/ — modify: use new profile/address tables + application/service/... — modify + infrastructure/persistence/... — modify + interfaces/http/handler.go — modify + tenant/ — new module + domain/entity/tenant.go — new + domain/repository/tenant.go — new + application/service/tenant.go — new + application/dto/tenant.go — new + infrastructure/persistence/... — new: sqlc + repos + interfaces/http/handlers.go — new + interfaces/http/routes.go — new + module.go — new + shared/events/ — modify if UserEvents change + todo/ — modify: add tenant_id to queries + auditlog/ — modify: add tenant_id + authorization/ — modify: add tenant_id to RBAC tables +``` + +--- + +### Task 1: Config, Context Keys, and Middleware + +**Files:** +- Modify: `internal/shared/config/config.go` +- Modify: `configs/config.yaml` +- Create: `internal/shared/middleware/tenant.go` +- Modify: `internal/shared/middleware/middleware.go` + +**Interfaces:** +- Produces: + ```go + // config.go + type TenantConfig struct { + Enabled bool `yaml:"enabled"` + TenantHeader string `yaml:"tenant_header"` + TenantJWTClaim string `yaml:"tenant_jwt_claim"` + Domain string `yaml:"domain"` + } + + // middleware/middleware.go + const TenantIDKey contextKey = "tenant_id" + func GetTenantID(ctx context.Context) string + + // middleware/tenant.go + func TenantResolver(cfg *config.TenantConfig) func(http.Handler) http.Handler + func getDomainFromHost(host, domainSuffix string) string + ``` + +- [ ] **Step 1: Add TenantConfig to config** + + In `internal/shared/config/config.go`, add: + ```go + type TenantConfig struct { + Enabled bool `yaml:"enabled"` + TenantHeader string `yaml:"tenant_header"` + TenantJWTClaim string `yaml:"tenant_jwt_claim"` + Domain string `yaml:"domain"` + } + ``` + + Add to `Config` struct: + ```go + Tenant TenantConfig `yaml:"multitenancy"` + ``` + + Add env overrides in `applyEnvOverrides()`: + ```go + c.Tenant.Enabled = getEnvBool("MULTITENANCY_ENABLED", c.Tenant.Enabled) + c.Tenant.TenantHeader = getEnv("MULTITENANCY_TENANT_HEADER", c.Tenant.TenantHeader) + c.Tenant.Domain = getEnv("MULTITENANCY_DOMAIN", c.Tenant.Domain) + ``` + + Also add `getEnvBool` helper if not present. + +- [ ] **Step 2: Update configs/config.yaml** + + Add to `configs/config.yaml`: + ```yaml + multitenancy: + enabled: false + tenant_header: "X-Tenant-ID" + tenant_jwt_claim: "tenant_id" + domain: "app.com" + ``` + +- [ ] **Step 3: Add TenantID to context keys** + + In `internal/shared/middleware/middleware.go`, add: + ```go + const TenantIDKey contextKey = "tenant_id" + ``` + + Add `GetTenantID`: + ```go + func GetTenantID(ctx context.Context) string { + if v, ok := ctx.Value(TenantIDKey).(string); ok { + return v + } + return "" + } + ``` + +- [ ] **Step 4: Create tenant middleware** + + Create `internal/shared/middleware/tenant.go`: + ```go + package middleware + + import ( + "context" + "net/http" + "strings" + + "github.com/IDTS-LAB/go-codebase/internal/shared/config" + ) + + func TenantResolver(cfg *config.TenantConfig) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenantID := "" + if cfg.Enabled { + tenantID = resolveTenant(r, cfg) + } + ctx := context.WithValue(r.Context(), TenantIDKey, tenantID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } + } + + func resolveTenant(r *http.Request, cfg *config.TenantConfig) string { + // 1. JWT claim (already in context after auth middleware) + if tid := GetTenantIDFromClaims(r.Context()); tid != "" { + return tid + } + // 2. Header override + if h := r.Header.Get(cfg.TenantHeader); h != "" { + return h + } + // 3. Subdomain + if sub := domainFromHost(r.Host, cfg.Domain); sub != "" && sub != "www" { + return sub + } + return "" + } + + func domainFromHost(host, domainSuffix string) string { + host = strings.Split(host, ":")[0] // strip port + if !strings.HasSuffix(host, "."+domainSuffix) { + return "" + } + return strings.TrimSuffix(host, "."+domainSuffix) + } + + func GetTenantIDFromClaims(ctx context.Context) string { + // Extract from JWT claims stored in context by auth middleware + // The auth middleware stores claims; we need a helper to get tenant_id + return "" + } + ``` + + Note: `GetTenantIDFromClaims` is a stub — it will be filled in Task 3 when JWT claims include TenantID. For now it returns empty string. + +- [ ] **Step 5: Build and test** + + Run: `go build ./...` + Expected: success. + +- [ ] **Step 6: Commit** + + ```bash + git add internal/shared/config/ internal/shared/middleware/ configs/config.yaml + git commit -m "feat(multitenancy): add config, context keys, and tenant resolver middleware" + ``` + +--- + +### Task 2: Wire TenantResolver into Router + +**Files:** +- Modify: `internal/shared/router/router.go` + +- [ ] **Step 1: Wire middleware** + + In `internal/shared/router/router.go`, add `TenantResolver` after `Auth` middleware: + ```go + r.Group(func(r chi.Router) { + r.Use(mw.Auth) + r.Use(middleware.TenantResolver(&cfg.Tenant)) // add here + r.Use(mw.MaxBodySize) + r.Mount("/todos", h.Todo) + r.Mount("/users", h.User) + r.Mount("/auth/sessions", h.Authz) + }) + ``` + + Add `middleware` import if needed. + +- [ ] **Step 2: Build** + + Run: `go build ./...` + Expected: success. + +- [ ] **Step 3: Commit** + + ```bash + git add internal/shared/router/router.go + git commit -m "feat(router): wire TenantResolver middleware" + ``` + +--- + +### Task 3: Add TenantID to JWT Claims and Token Generation + +**Files:** +- Modify: `internal/core/domain/token.go` +- Modify: `internal/infrastructure/auth/jwt.go` +- Modify: `internal/authentication/application/service/authentication_service.go` +- Modify: `internal/shared/middleware/tenant.go` (fill GetTenantIDFromClaims) + +**Interfaces:** +- Consumes: `middleware.GetTenantID(ctx)` from Task 1. +- Produces: `TokenClaims.TenantID string` stored in JWT and context. + +- [ ] **Step 1: Add TenantID to TokenClaims** + + In `internal/core/domain/token.go`: + ```go + type TokenClaims struct { + UserID string + Email string + Role string + JTI string + TenantID string + } + ``` + +- [ ] **Step 2: Update JWT generation** + + In `internal/infrastructure/auth/jwt.go`: + ```go + // In GenerateToken, add after Role: + claims["tenant_id"] = tc.TenantID + ``` + + In `ValidateToken`, add after parsing: + ```go + tenantID, _ := parsedToken.Claims.Get("tenant_id") + // Set TenantID on the returned TokenClaims + ``` + +- [ ] **Step 3: Pass TenantID when generating tokens** + + In `authentication_service.go`, when creating `domain.TokenClaims`, set `TenantID` from context: + ```go + tenantID := middleware.GetTenantID(ctx) + claims := domain.TokenClaims{ + UserID: user.ID.String(), + Email: user.Email, + Role: user.Role, + JTI: uuid.New().String(), + TenantID: tenantID, + } + ``` + + Add `"github.com/IDTS-LAB/go-codebase/internal/shared/middleware"` import. + +- [ ] **Step 4: Fill GetTenantIDFromClaims** + + In `internal/shared/middleware/tenant.go`, implement the stub: + ```go + func GetTenantIDFromClaims(ctx context.Context) string { + return GetUserClaim(ctx, "tenant_id") + } + ``` + Or, since auth middleware stores claims in context, add a generic claim getter or use the specific claim from stored UserID/Email/Role pattern. + + The cleanest approach: add `GetUserClaim` helper or read from JWT claims store. Since the auth middleware stores claims directly, add: + ```go + const TenantClaimKey contextKey = "tenant_claim" + func SetTenantClaim(ctx context.Context, tenantID string) context.Context { + return context.WithValue(ctx, TenantClaimKey, tenantID) + } + func GetTenantIDFromClaims(ctx context.Context) string { + if v, ok := ctx.Value(TenantClaimKey).(string); ok { + return v + } + return "" + } + ``` + + Set it in `authentication_service.go` when creating claims: + ```go + ctx = middleware.SetTenantClaim(ctx, tenantID) + ``` + +- [ ] **Step 5: Build and test** + + Run: `go build ./... && go test ./internal/authentication/...` + Expected: success. + +- [ ] **Step 6: Commit** + + ```bash + git add internal/core/domain/token.go internal/infrastructure/auth/jwt.go \ + internal/authentication/application/service/authentication_service.go \ + internal/shared/middleware/tenant.go + git commit -m "feat(jwt): add TenantID to token claims and generation" + ``` + +--- + +### Task 4: Create Tenant Module (Entity, Service, Repository, Handlers) + +**Files:** +- Create: `internal/tenant/domain/entity/tenant.go` +- Create: `internal/tenant/domain/repository/tenant.go` +- Create: `internal/tenant/application/dto/tenant.go` +- Create: `internal/tenant/application/service/tenant.go` +- Create: `internal/tenant/infrastructure/persistence/sqlc/queries.sql` +- Create: `internal/tenant/infrastructure/persistence/tenant_repository.go` +- Create: `internal/tenant/interfaces/http/handlers.go` +- Create: `internal/tenant/interfaces/http/routes.go` +- Create: `internal/tenant/module.go` + +**Interfaces:** +- Consumes: config, database, logger from Fx. +- Produces: Tenant CRUD handlers under `/api/v1/admin/tenants`. + +- [ ] **Step 1: Create domain entity** + + `internal/tenant/domain/entity/tenant.go`: + ```go + package entity + + import ( + "time" + "encoding/json" + "github.com/google/uuid" + ) + + type Tenant struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Domain *string `json:"domain,omitempty"` + Settings json.RawMessage `json:"settings"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } + ``` + +- [ ] **Step 2: Create repository interface** + + `internal/tenant/domain/repository/tenant.go`: + ```go + package repository + + import ( + "context" + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/entity" + "github.com/google/uuid" + ) + + type TenantRepository interface { + Create(ctx context.Context, t *entity.Tenant) error + GetByID(ctx context.Context, id uuid.UUID) (*entity.Tenant, error) + GetBySlug(ctx context.Context, slug string) (*entity.Tenant, error) + List(ctx context.Context, offset, limit int) ([]entity.Tenant, int, error) + Update(ctx context.Context, t *entity.Tenant) error + Delete(ctx context.Context, id uuid.UUID) error + } + ``` + +- [ ] **Step 3: Create DTOs** + + `internal/tenant/application/dto/tenant.go`: + ```go + package dto + + import "encoding/json" + + type CreateTenantRequest struct { + Name string `json:"name" validate:"required"` + Slug string `json:"slug" validate:"required"` + Domain *string `json:"domain"` + Settings json.RawMessage `json:"settings"` + } + + type UpdateTenantRequest struct { + Name *string `json:"name"` + Domain *string `json:"domain"` + Settings json.RawMessage `json:"settings"` + IsActive *bool `json:"is_active"` + } + + type TenantResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Domain *string `json:"domain,omitempty"` + Settings json.RawMessage `json:"settings"` + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + ``` + +- [ ] **Step 4: Create service** + + `internal/tenant/application/service/tenant.go`: + ```go + package service + + import ( + "context" + "errors" + "time" + + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/repository" + "github.com/google/uuid" + ) + + var ( + ErrTenantNotFound = errors.New("tenant not found") + ErrTenantExists = errors.New("tenant slug already exists") + ) + + type TenantService struct { + repo repository.TenantRepository + } + + func NewTenantService(repo repository.TenantRepository) *TenantService { + return &TenantService{repo: repo} + } + + func (s *TenantService) Create(ctx context.Context, name, slug string, domain *string, settings []byte) (*entity.Tenant, error) { + tenant := &entity.Tenant{ + ID: uuid.New(), + Name: name, + Slug: slug, + Domain: domain, + Settings: settings, + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := s.repo.Create(ctx, tenant); err != nil { + return nil, ErrTenantExists + } + return tenant, nil + } + + func (s *TenantService) GetByID(ctx context.Context, id uuid.UUID) (*entity.Tenant, error) { + tenant, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, ErrTenantNotFound + } + return tenant, nil + } + + func (s *TenantService) List(ctx context.Context, page, perPage int) ([]entity.Tenant, int, error) { + offset := (page - 1) * perPage + return s.repo.List(ctx, offset, perPage) + } + + func (s *TenantService) Update(ctx context.Context, id uuid.UUID, req interface{}) (*entity.Tenant, error) { ... } + func (s *TenantService) Delete(ctx context.Context, id uuid.UUID) error { ... } + ``` + +- [ ] **Step 5: Create sqlc queries** + + `internal/tenant/infrastructure/persistence/sqlc/queries.sql`: + ```sql + -- name: CreateTenant :exec + INSERT INTO tenants (id, name, slug, domain, settings, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8); + + -- name: GetTenantByID :one + SELECT * FROM tenants WHERE id = $1; + + -- name: GetTenantBySlug :one + SELECT * FROM tenants WHERE slug = $1; + + -- name: ListTenants :many + SELECT * FROM tenants ORDER BY created_at DESC LIMIT $1 OFFSET $2; + + -- name: UpdateTenant :exec + UPDATE tenants SET name = $2, domain = $3, settings = $4, is_active = $5, updated_at = now() WHERE id = $1; + + -- name: DeleteTenant :exec + DELETE FROM tenants WHERE id = $1; + ``` + +- [ ] **Step 6: Create infrastructure repository** + + Implement `TenantRepository` using sqlc-generated code. + +- [ ] **Step 7: Create HTTP handlers + routes + Fx module** + + Wire everything with Fx. Mount at `/api/v1/admin/tenants`. + +- [ ] **Step 8: Build and test** + + Run: `go build ./...` + Expected: success. + +- [ ] **Step 9: Commit** + + ```bash + git add internal/tenant/ + git commit -m "feat(tenant): add tenant management module" + ``` + +--- + +### Task 5: Migration 007 — Create Tenants Table + +**Files:** +- Create: `migrations/007_create_tenants.sql` + +- [ ] **Step 1: Write migration** + + `migrations/007_create_tenants.sql`: + ```sql + -- +goose Up + CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + domain VARCHAR(255), + settings JSONB NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + -- +goose Down + DROP TABLE IF EXISTS tenants; + ``` + +- [ ] **Step 2: Commit** + + ```bash + git add migrations/007_create_tenants.sql + git commit -m "feat(migrations): create tenants table" + ``` + +--- + +### Task 6: Migration 008 — Normalize User Tables + +**Files:** +- Create: `migrations/008_normalize_users.sql` + +- [ ] **Step 1: Write the migration** + + Write the complete Up migration that creates all 9 new tables, copies data from old `users` table, and drops the old columns. + + The old `users` table has columns: id, email, password_hash, name, is_active, email_verified_at, verification_token, verification_token_expires_at, reset_token, reset_token_expires_at, login_attempts, locked_until, last_login_at, created_at, updated_at, deleted_at. + + Write Down migration that reverses the split (re-adds columns, copies data back). + + See `docs/superpowers/specs/2026-07-12-multitenancy-design.md` for the exact table schemas. + +- [ ] **Step 2: Commit** + + ```bash + git add migrations/008_normalize_users.sql + git commit -m "feat(migrations): normalize users into purpose-specific tables" + ``` + +--- + +### Task 7: Migration 009 — Add tenant_id to Domain Tables + +**Files:** +- Create: `migrations/009_add_tenant_id.sql` + +- [ ] **Step 1: Write the migration** + + Add `tenant_id VARCHAR(36) NOT NULL DEFAULT ''` to: todos, users, roles, permissions, user_roles, role_permissions, audit_logs, error_logs. + + Add composite indexes: `(tenant_id, created_at)`, `(tenant_id, id)`. + +- [ ] **Step 2: Commit** + + ```bash + git add migrations/009_add_tenant_id.sql + git commit -m "feat(migrations): add tenant_id column to domain tables" + ``` + +--- + +### Task 8: Update Auth Module for New User Tables + +**Files:** +- Modify: `internal/authentication/domain/entity/user.go` +- Modify: `internal/authentication/domain/entity/refresh_token.go` +- Modify: `internal/authentication/domain/repository/user_repository.go` +- Modify: `internal/authentication/domain/repository/refresh_token_repository.go` +- Modify: `internal/authentication/application/service/authentication_service.go` +- Modify: `internal/authentication/infrastructure/persistence/` (all files) +- Modify: `internal/authentication/infrastructure/persistence/sqlc/queries.sql` +- Modify: `internal/shared/auditlog/` (add tenant_id) + +**Scope:** Refactor auth module to use new user_credentials, user_security, user_tokens, user_sessions tables instead of the old monolithic users table. + +- [ ] **Step 1: Update domain entities** + + `User` entity no longer has password_hash, login_attempts, locked_until, etc. Keep only: ID, Email, Name, IsActive, EmailVerifiedAt, TenantID, timestamps. + + Create new entities for `UserCredential`, `UserSecurity`, `UserToken`, `UserSession` in the auth module's domain. + +- [ ] **Step 2: Update repository interfaces** + + `UserRepository`: + - Remove `UpdatePassword`, `GetByRefreshToken`, `IncrementLoginAttempts`, `LockAccount`, `ResetLoginAttempts` + - Add credential/security lookup methods or a separate `CredentialRepository` + +- [ ] **Step 3: Update sqlc queries** + + Add queries for the new tables. Generate new sqlc models. + +- [ ] **Step 4: Update service layer** + + `AuthenticationService` uses the new repositories for register, login, verify, reset, refresh, logout flows. + +- [ ] **Step 5: Build and test** + + Run: `go build ./... && go test ./internal/authentication/...` + Expected: success. Fix any test that references old entity fields. + +- [ ] **Step 6: Commit** + + ```bash + git add internal/authentication/ + git commit -m "refactor(auth): use normalized user tables" + ``` + +--- + +### Task 9: Update User Module for New Profile/Address/Preference Tables + +**Files:** +- Modify: `internal/user/domain/entity/` (if any) +- Modify: `internal/user/domain/repository/user_repository.go` +- Modify: `internal/user/application/service/` +- Modify: `internal/user/infrastructure/persistence/` +- Modify: `internal/user/interfaces/http/handler.go` + +**Scope:** Update user module to read/write from user_profiles and user_addresses tables. + +- [ ] **Step 1: Update repository and service** + + `UserRepository` gains `GetProfile(ctx, userID)`, `UpdateProfile(ctx, userID, ...)`, `ListAddresses(ctx, userID)`, `CreateAddress`, etc. + +- [ ] **Step 2: Build and test** + + Run: `go build ./... && go test ./internal/user/...` + Expected: success. + +- [ ] **Step 3: Commit** + + ```bash + git add internal/user/ + git commit -m "refactor(user): use profile, address, and preference tables" + ``` + +--- + +### Task 10: Add tenant_id Filtering to All Repositories + +**Files:** +- Modify: `internal/todo/infrastructure/persistence/` (sqlc + repository) +- Modify: `internal/todo/infrastructure/persistence/sqlc/queries.sql` +- Modify: `internal/authorization/infrastructure/persistence/` (sqlc + repository) +- Modify: `internal/authorization/infrastructure/persistence/sqlc/queries.sql` +- Modify: `internal/auditlog/` (sqlc + repository) + +**Scope:** Add `tenant_id` field to all CreateParams and `WHERE tenant_id = $N` to all queries. + +- [ ] **Step 1: Add tenantFromCtx helper** + + In `internal/shared/middleware/tenant.go` or a shared utils file, add: + ```go + func TenantFromCtx(ctx context.Context) string { + return GetTenantID(ctx) + } + ``` + +- [ ] **Step 2-5: Update each module's sqlc and repository** + + For each module: + 1. Add `tenant_id` to CREATE params + 2. Filter SELECT/UPDATE/DELETE by tenant_id + 3. Regenerate sqlc code + 4. Build and test + +- [ ] **Step 6: Commit** + + ```bash + git add internal/todo/ internal/authorization/ internal/shared/auditlog/ + git commit -m "feat(multitenancy): add tenant_id filtering to all repositories" + ``` + +--- + +### Task 11: Update Todo Command Handlers for Tenant Context + +**Files:** +- Modify: `internal/todo/application/command/` (create, update, delete, complete, list) + +**Scope:** Pass tenant context from middleware through to repository. + +- [ ] **Step 1: Pass tenant context** + + In command handlers, ensure context propagation passes tenant_id. + +- [ ] **Step 2: Build and test** + + Run: `go build ./... && go test ./internal/todo/...` + Expected: success. + +- [ ] **Step 3: Commit** + + ```bash + git add internal/todo/application/command/ + git commit -m "feat(todo): pass tenant context through command handlers" + ``` + +--- + +### Task 12: Update Authorization Module for Tenant Scope + +**Files:** +- Modify: `internal/authorization/infrastructure/casbin/enforcer.go` +- Modify: `internal/authorization/application/service/authorization_service.go` + +**Scope:** Casbin policies can optionally be scoped to tenant. When multitenancy is enabled, role names may include tenant prefix or Casbin policies get a tenant_id field. + +- [ ] **Step 1: Update Casbin enforcer** + + Add tenant-aware policy checking. + +- [ ] **Step 2: Build and test** + + Run: `go build ./... && go test ./internal/authorization/...` + Expected: success. + +- [ ] **Step 3: Commit** + + ```bash + git add internal/authorization/ + git commit -m "feat(authz): add tenant-aware Casbin policy scope" + ``` + +--- + +### Task 13: Audit Log Tenant Context + +**Files:** +- Modify: `internal/shared/auditlog/` (entity, repository, middleware) + +**Scope:** Include tenant_id in audit log entries. + +- [ ] **Step 1: Add tenant_id to AuditLog entity and Insert params** + +- [ ] **Step 2: Build and test** + +- [ ] **Step 3: Commit** + +--- + +### Task 14: Full Integration and Test Pass + +- [ ] **Step 1: Run full suite** + + Run: `go build ./... && go vet ./... && go test ./...` + Expected: all pass. + +- [ ] **Step 2: Fix any failures** + +- [ ] **Step 3: Commit** + + ```bash + git add -A + git commit -m "chore: fix tests and build after multitenancy refactor" + ``` diff --git a/docs/superpowers/plans/2026-07-13-casbin-cursor-sqlc-plan.md b/docs/superpowers/plans/2026-07-13-casbin-cursor-sqlc-plan.md new file mode 100644 index 0000000..ded1fba --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-casbin-cursor-sqlc-plan.md @@ -0,0 +1,2392 @@ +# Casbin, Cursor Pagination, sqlc Migration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate Casbin to standard `casbin_rule` table with custom adapter, replace offset/limit pagination with bidirectional cursor pagination across 5 domains, and migrate simple CRUD in `user` and `todo` repositories to sqlc. + +**Architecture:** Three independent subsystems: (1) sqlc migration + pagination cleanup, (2) cursor pagination shared utility + repository/query/handler changes, (3) Casbin standard adapter + sync flow. Implemented in that order to minimize merge conflicts. + +**Tech Stack:** Go 1.x, `database/sql`, `casbin/v2`, `sqlc`, `chi/v5`, `go.uber.org/fx` + +## Global Constraints + +- All new signatures must be backward-aware: old callers of `GetAll(ctx, offset, limit)` become callers of `GetAll(ctx, cursor, limit)` +- The `internal/shared/utils/APIResponse.Meta` field changes to `interface{}` (was `*PaginationMeta`, now either `*PaginationMeta` or `*CursorMeta`) +- All sqlc `.sql` files in `queries/` directories get pagination queries removed, then regenerated +- No new third-party dependencies — Casbin adapter is custom, cursor encoding is `encoding/json` + `base64` + +--- + +### Task 1: Fix sqlc `UpdateUser` query before migration + +**Files:** +- Modify: `internal/user/infrastructure/persistence/queries/queries.sql:12-13` +- Verify: `internal/user/infrastructure/persistence/sqlc/queries.sql.go` (auto-generated) + +**Issue:** Current `UpdateUser` sets `deleted_at = $6` without `WHERE deleted_at IS NULL`, which could undelete soft-deleted users. + +- [ ] **Step 1: Fix the SQL query** + +Replace lines 12-13 of `internal/user/infrastructure/persistence/queries/queries.sql`: + +```sql +-- name: UpdateUser :execrows +UPDATE users SET email = $2, name = $3, is_active = $4, updated_at = $5 WHERE id = $1 AND deleted_at IS NULL; +``` + +- [ ] **Step 2: Regenerate sqlc** + +```bash +sqlc generate +``` + +Verify `internal/user/infrastructure/persistence/sqlc/queries.sql.go` now has `UpdateUser` with 5 params and `WHERE deleted_at IS NULL`. + +- [ ] **Step 3: Run existing tests to confirm no regression** + +```bash +go test ./internal/user/... -v -count=1 2>&1 | tail -20 +``` + +Expected: tests pass (or skip if no tests exist). + +- [ ] **Step 4: Commit** + +```bash +git add internal/user/infrastructure/persistence/queries/queries.sql internal/user/infrastructure/persistence/sqlc/ +git commit -m "fix: UpdateUser sqlc query uses WHERE deleted_at IS NULL and excludes deleted_at SET" +``` + +--- + +### Task 2: Create shared cursor package + +**Files:** +- Create: `internal/shared/cursor/cursor.go` +- Create: `internal/shared/cursor/cursor_test.go` + +**Interfaces:** +- Consumes: `time`, `uuid` +- Produces: `cursor.Encode(t time.Time, id uuid.UUID) string`, `cursor.Decode(s string) (Cursor, error)`, `Cursor{Timestamp time.Time, ID uuid.UUID}` + +- [ ] **Step 1: Write the test first** + +**File:** `internal/shared/cursor/cursor_test.go` + +```go +package cursor + +import ( + "testing" + "time" + "github.com/google/uuid" +) + +func TestEncodeDecode(t *testing.T) { + now := time.Now().UTC().Truncate(time.Microsecond) + id := uuid.New() + + token := Encode(now, id) + if token == "" { + t.Fatal("expected non-empty token") + } + + c, err := Decode(token) + if err != nil { + t.Fatalf("decode error: %v", err) + } + + if !c.Timestamp.Equal(now) { + t.Errorf("timestamp mismatch: got %v, want %v", c.Timestamp, now) + } + if c.ID != id { + t.Errorf("id mismatch: got %v, want %v", c.ID, id) + } +} + +func TestDecodeInvalid(t *testing.T) { + _, err := Decode("invalid-base64!") + if err == nil { + t.Fatal("expected error for invalid token") + } + + _, err = Decode("") + if err == nil { + t.Fatal("expected error for empty token") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/shared/cursor/ -v -count=1 +``` + +Expected: FAIL with "package does not exist" or compile error. + +- [ ] **Step 3: Implement cursor package** + +**File:** `internal/shared/cursor/cursor.go` + +```go +package cursor + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "time" + "github.com/google/uuid" +) + +type Cursor struct { + Timestamp time.Time `json:"t"` + ID uuid.UUID `json:"i"` +} + +func Encode(t time.Time, id uuid.UUID) string { + c := Cursor{Timestamp: t.UTC(), ID: id} + b, _ := json.Marshal(c) + return base64.URLEncoding.EncodeToString(b) +} + +func Decode(s string) (Cursor, error) { + if s == "" { + return Cursor{}, fmt.Errorf("empty cursor") + } + b, err := base64.URLEncoding.DecodeString(s) + if err != nil { + return Cursor{}, fmt.Errorf("decode cursor: %w", err) + } + var c Cursor + if err := json.Unmarshal(b, &c); err != nil { + return Cursor{}, fmt.Errorf("unmarshal cursor: %w", err) + } + return c, nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test ./internal/shared/cursor/ -v -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/shared/cursor/ +git commit -m "feat: add shared cursor package for cursor-based pagination" +``` + +--- + +### Task 3: Add CursorMeta and RespondCursorPaginated to utils + +**Files:** +- Modify: `internal/shared/utils/utils.go` +- Modify: `internal/shared/utils/handler.go` + +**Interfaces:** +- Consumes: `cursor.Cursor` +- Produces: `utils.CursorMeta`, `utils.RespondCursorPaginated(w, data, nextCursor, prevCursor, hasNext, hasPrev, limit)`, `utils.HandleCursorPaginated(w, data, nextCursor, prevCursor, hasNext, hasPrev, limit, err)` + +- [ ] **Step 1: Update `APIResponse.Meta` to `interface{}`** + +Replace line 14 in `internal/shared/utils/utils.go`: + +```go +Meta interface{} `json:"meta"` +``` + +- [ ] **Step 2: Add CursorMeta to `internal/shared/utils/utils.go`** + +Add after `PaginationMeta` block (after line 23): + +```go +type CursorMeta struct { + NextCursor *string `json:"next_cursor"` + PrevCursor *string `json:"prev_cursor"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` + Limit int `json:"limit"` +} +``` + +- [ ] **Step 3: Add `RespondCursorPaginated` to `internal/shared/utils/utils.go`** + +Add after `RespondPaginated` (after line 71): + +```go +func RespondCursorPaginated(w http.ResponseWriter, data interface{}, nextCursor, prevCursor *string, hasNext, hasPrev bool, limit int) { + RespondJSON(w, http.StatusOK, APIResponse{ + Success: true, + Data: data, + Meta: CursorMeta{ + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + Limit: limit, + }, + }) +} +``` + +- [ ] **Step 4: Add `HandleCursorPaginated` to `internal/shared/utils/handler.go`** + +Add after `HandlePaginated` (after line 43): + +```go +func HandleCursorPaginated(w http.ResponseWriter, data interface{}, nextCursor, prevCursor *string, hasNext, hasPrev bool, limit int, err error) { + if err != nil { + MapError(w, err) + return + } + RespondCursorPaginated(w, data, nextCursor, prevCursor, hasNext, hasPrev, limit) +} +``` + +- [ ] **Step 5: Update formatter middleware to handle CursorMeta** + +In `internal/shared/middleware/formatter.go`, update the pagination check section (lines 51-65) to also check for `cursor_meta`: + +Replace lines 51-65: + +```go +var paginated struct { + Data interface{} `json:"data"` + Pagination interface{} `json:"pagination"` +} +if json.Unmarshal(fw.body, &paginated) == nil && paginated.Data != nil && paginated.Pagination != nil { + var meta utils.PaginationMeta + metaBytes, _ := json.Marshal(paginated.Pagination) + json.Unmarshal(metaBytes, &meta) + json.NewEncoder(w).Encode(utils.APIResponse{ + Success: true, + Data: paginated.Data, + Meta: &meta, + }) + return +} + +var cursorResp struct { + Data interface{} `json:"data"` + Meta interface{} `json:"meta"` +} +if json.Unmarshal(fw.body, &cursorResp) == nil && cursorResp.Data != nil && cursorResp.Meta != nil { + var meta utils.CursorMeta + metaBytes, _ := json.Marshal(cursorResp.Meta) + json.Unmarshal(metaBytes, &meta) + json.NewEncoder(w).Encode(utils.APIResponse{ + Success: true, + Data: cursorResp.Data, + Meta: &meta, + }) + return +} +``` + +- [ ] **Step 6: Build check** + +```bash +go build ./... +``` + +Expected: no compile errors. + +- [ ] **Step 7: Commit** + +```bash +git add internal/shared/utils/utils.go internal/shared/utils/handler.go internal/shared/middleware/formatter.go +git commit -m "feat: add CursorMeta, RespondCursorPaginated, update formatter" +``` + +--- + +### Task 4: Cleanup pagination queries from sqlc + regenerate + +**Files:** +- Modify: `internal/todo/infrastructure/persistence/queries/todo.sql` (remove ListTodos, CountTodos, SearchTodos, CountSearchTodos) +- Modify: `internal/user/infrastructure/persistence/queries/queries.sql` (remove CountUsers, ListUsers) +- Modify: `internal/authorization/infrastructure/persistence/queries/queries.sql` (remove ListRoles, CountRoles, ListPermissions, CountPermissions) +- Modify: `internal/tenant/infrastructure/persistence/queries/queries.sql` (remove ListTenants, CountTenants) +- All `sqlc/` dirs will be regenerated + +- [ ] **Step 1: Clean `todo.sql`** + +Replace `internal/todo/infrastructure/persistence/queries/todo.sql` with: + +```sql +-- name: CreateTodo :exec +INSERT INTO todos (id, title, description, completed, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6); + +-- name: GetTodoByID :one +SELECT id, title, description, completed, created_at, updated_at, deleted_at +FROM todos +WHERE id = $1 AND deleted_at IS NULL; + +-- name: UpdateTodo :execrows +UPDATE todos +SET title = $2, description = $3, completed = $4, updated_at = $5 +WHERE id = $1 AND deleted_at IS NULL; + +-- name: SoftDeleteTodo :execrows +UPDATE todos +SET deleted_at = NOW(), updated_at = NOW() +WHERE id = $1 AND deleted_at IS NULL; +``` + +- [ ] **Step 2: Clean `user/queries.sql`** + +Replace `internal/user/infrastructure/persistence/queries/queries.sql` with: + +```sql +-- name: GetUserByID :one +SELECT id, email, name, is_active, created_at, updated_at, deleted_at +FROM users WHERE id = $1 AND deleted_at IS NULL; + +-- name: UpdateUser :execrows +UPDATE users SET email = $2, name = $3, is_active = $4, updated_at = $5 WHERE id = $1 AND deleted_at IS NULL; + +-- name: DeleteUser :execrows +UPDATE users SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL; +``` + +- [ ] **Step 3: Clean `authorization/queries.sql`** + +Remove lines 13-18 (ListRoles, CountRoles) and lines 38-43 (ListPermissions, CountPermissions) from `internal/authorization/infrastructure/persistence/queries/queries.sql`. + +The remaining file should keep: CreateRole, GetRoleByID, GetRoleByName, UpdateRole, DeleteRole, CreatePermission, GetPermissionByID, GetPermissionByName, UpdatePermission, DeletePermission, AssignRolePermission, RemoveRolePermission, GetRolePermissionsByRoleID, GetPermissionsByRoleID, AssignUserRole, RemoveUserRole, GetUserRolesByUserID, GetRolesByUserID. + +- [ ] **Step 4: Clean `tenant/queries.sql`** + +Remove lines 13-18 (ListTenants, CountTenants) from `internal/tenant/infrastructure/persistence/queries/queries.sql`. + +- [ ] **Step 5: Regenerate all sqlc** + +```bash +sqlc generate +``` + +Verify no errors. Check that the generated files no longer contain ListTodos, CountTodos, SearchTodos, CountSearchTodos, ListUsers, CountUsers, ListRoles, CountRoles, ListPermissions, CountPermissions, ListTenants, CountTenants. + +- [ ] **Step 6: Build check** + +```bash +go build ./... +``` + +Expected: pass (the generated code removed those query functions, but they weren't being called). + +- [ ] **Step 7: Commit** + +```bash +git add internal/todo/infrastructure/persistence/queries/ internal/user/infrastructure/persistence/queries/ internal/authorization/infrastructure/persistence/queries/ internal/tenant/infrastructure/persistence/queries/ internal/todo/infrastructure/persistence/sqlc/ internal/user/infrastructure/persistence/sqlc/ internal/authorization/infrastructure/persistence/sqlc/ internal/tenant/infrastructure/persistence/sqlc/ +git commit -m "refactor: remove pagination queries from sqlc, keep only CRUD" +``` + +--- + +### Task 5: Migrate user repository to sqlc (simple CRUD) + +**Files:** +- Modify: `internal/user/infrastructure/persistence/user_repository.go` + +**Interfaces:** +- Consumes: `sqlc.New(r.db)` from existing generated code +- Produces: `GetByID`, `Update`, `Delete` use sqlc; `List` stays raw SQL + +- [ ] **Step 1: Update `GetByID` to use sqlc** + +Replace lines 82-99 in `internal/user/infrastructure/persistence/user_repository.go`: + +```go +func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.User, error) { + q := sqlc.New(r.db) + row, err := q.GetUserByID(ctx, id) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("user not found") + } + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + var u entity.User + u.ID = row.ID + u.Email = row.Email + u.Name = row.Name + u.IsActive = row.IsActive + u.CreatedAt = row.CreatedAt + u.UpdatedAt = row.UpdatedAt + if row.DeletedAt.Valid { + u.DeletedAt = &row.DeletedAt.Time + } + return &u, nil +} +``` + +- [ ] **Step 2: Update `Update` to use sqlc** + +Replace lines 101-116: + +```go +func (r *userRepository) Update(ctx context.Context, user *entity.User) error { + q := sqlc.New(r.db) + rows, err := q.UpdateUser(ctx, sqlc.UpdateUserParams{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + IsActive: user.IsActive, + UpdatedAt: time.Now().UTC(), + }) + if err != nil { + return fmt.Errorf("update user: %w", err) + } + if rows == 0 { + return fmt.Errorf("user not found") + } + return nil +} +``` + +Add `"time"` to the imports in the file. + +- [ ] **Step 3: Update `Delete` to use sqlc** + +Replace lines 118-133: + +```go +func (r *userRepository) Delete(ctx context.Context, id uuid.UUID) error { + q := sqlc.New(r.db) + rows, err := q.DeleteUser(ctx, id) + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + if rows == 0 { + return fmt.Errorf("user not found") + } + return nil +} +``` + +- [ ] **Step 4: Build check** + +```bash +go build ./... +``` + +Expected: pass. + +- [ ] **Step 5: Commit** + +```bash +git add internal/user/infrastructure/persistence/user_repository.go +git commit -m "feat: migrate user repository CRUD (GetByID, Update, Delete) to sqlc" +``` + +--- + +### Task 6: Migrate todo repository to sqlc (simple CRUD) + +**Files:** +- Modify: `internal/todo/infrastructure/persistence/todo_repository.go` + +- [ ] **Step 1: Add sqlc import to todo_repository.go** + +Add to the imports: + +```go +"github.com/IDTS-LAB/go-codebase/internal/todo/infrastructure/persistence/sqlc" +``` + +- [ ] **Step 2: Update `Create` to use sqlc** + +Replace lines 24-33: + +```go +func (r *todoRepository) Create(ctx context.Context, todo *entity.Todo) error { + q := sqlc.New(r.db) + err := q.CreateTodo(ctx, sqlc.CreateTodoParams{ + ID: todo.ID, + Title: todo.Title, + Description: todo.Description, + Completed: todo.Completed, + CreatedAt: todo.CreatedAt, + UpdatedAt: todo.UpdatedAt, + }) + if err != nil { + return fmt.Errorf("insert todo: %w", err) + } + return nil +} +``` + +- [ ] **Step 3: Update `GetByID` to use sqlc** + +Replace lines 35-52: + +```go +func (r *todoRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.Todo, error) { + q := sqlc.New(r.db) + row, err := q.GetTodoByID(ctx, id) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("todo not found") + } + if err != nil { + return nil, fmt.Errorf("get todo: %w", err) + } + todo := &entity.Todo{ + ID: row.ID, + Title: row.Title, + Description: row.Description, + Completed: row.Completed, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + if row.DeletedAt.Valid { + todo.DeletedAt = &row.DeletedAt.Time + } + return todo, nil +} +``` + +- [ ] **Step 4: Update `Update` to use sqlc** + +Replace lines 112-127: + +```go +func (r *todoRepository) Update(ctx context.Context, todo *entity.Todo) error { + q := sqlc.New(r.db) + rows, err := q.UpdateTodo(ctx, sqlc.UpdateTodoParams{ + ID: todo.ID, + Title: todo.Title, + Description: todo.Description, + Completed: todo.Completed, + UpdatedAt: todo.UpdatedAt, + }) + if err != nil { + return fmt.Errorf("update todo: %w", err) + } + if rows == 0 { + return fmt.Errorf("todo not found") + } + return nil +} +``` + +- [ ] **Step 5: Update `Delete` to use sqlc** + +Replace lines 129-144: + +```go +func (r *todoRepository) Delete(ctx context.Context, id uuid.UUID) error { + q := sqlc.New(r.db) + rows, err := q.SoftDeleteTodo(ctx, id) + if err != nil { + return fmt.Errorf("delete todo: %w", err) + } + if rows == 0 { + return fmt.Errorf("todo not found") + } + return nil +} +``` + +- [ ] **Step 6: Build check** + +```bash +go build ./... +``` + +Expected: pass. + +- [ ] **Step 7: Commit** + +```bash +git add internal/todo/infrastructure/persistence/todo_repository.go +git commit -m "feat: migrate todo repository CRUD (Create, GetByID, Update, Delete) to sqlc" +``` + +--- + +### Task 7: Update repository interfaces for cursor pagination + +**Files:** +- Modify: `internal/todo/domain/repository/todo_repository.go` +- Modify: `internal/user/domain/repository/user_repository.go` +- Modify: `internal/tenant/domain/repository/tenant.go` +- Modify: `internal/authorization/domain/repository/authorization_repository.go` +- Modify: `internal/core/domain/repository.go` (optional, generic interface) + +- [ ] **Step 1: Update `TodoRepository`** + +Replace `internal/todo/domain/repository/todo_repository.go`: + +```go +package repository + +import ( + "context" + "github.com/IDTS-LAB/go-codebase/internal/todo/domain/entity" + "github.com/google/uuid" +) + +type TodoRepository interface { + Create(ctx context.Context, todo *entity.Todo) error + GetByID(ctx context.Context, id uuid.UUID) (*entity.Todo, error) + GetAll(ctx context.Context, cursor *string, limit int) ([]*entity.Todo, *string, *string, bool, bool, error) + Update(ctx context.Context, todo *entity.Todo) error + Delete(ctx context.Context, id uuid.UUID) error + Search(ctx context.Context, query string, cursor *string, limit int) ([]*entity.Todo, *string, *string, bool, bool, error) +} +``` + +- [ ] **Step 2: Update `UserRepository`** + +Replace `internal/user/domain/repository/user_repository.go`: + +```go +package repository + +import ( + "context" + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/google/uuid" +) + +type UserRepository interface { + List(ctx context.Context, cursor *string, limit int) ([]*entity.User, *string, *string, bool, bool, error) + GetByID(ctx context.Context, id uuid.UUID) (*entity.User, error) + Update(ctx context.Context, user *entity.User) error + Delete(ctx context.Context, id uuid.UUID) error +} +``` + +- [ ] **Step 3: Update `TenantRepository`** + +Replace `internal/tenant/domain/repository/tenant.go`: + +```go +package repository + +import ( + "context" + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/entity" + "github.com/google/uuid" +) + +type TenantRepository interface { + Create(ctx context.Context, t *entity.Tenant) error + GetByID(ctx context.Context, id uuid.UUID) (*entity.Tenant, error) + GetBySlug(ctx context.Context, slug string) (*entity.Tenant, error) + List(ctx context.Context, cursor *string, limit int) ([]entity.Tenant, *string, *string, bool, bool, error) + Update(ctx context.Context, t *entity.Tenant) error + Delete(ctx context.Context, id uuid.UUID) error +} +``` + +- [ ] **Step 4: Update `RoleRepository` and `PermissionRepository`** + +Replace `internal/authorization/domain/repository/authorization_repository.go`: + +```go +package repository + +import ( + "context" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + "github.com/google/uuid" +) + +type RoleRepository interface { + Create(ctx context.Context, role *entity.Role) error + GetByID(ctx context.Context, id uuid.UUID) (*entity.Role, error) + GetByName(ctx context.Context, name string) (*entity.Role, error) + GetAll(ctx context.Context, cursor *string, limit int) ([]*entity.Role, *string, *string, bool, bool, error) + Update(ctx context.Context, role *entity.Role) error + Delete(ctx context.Context, id uuid.UUID) error +} + +type PermissionRepository interface { + Create(ctx context.Context, perm *entity.Permission) error + GetByID(ctx context.Context, id uuid.UUID) (*entity.Permission, error) + GetByName(ctx context.Context, name string) (*entity.Permission, error) + GetAll(ctx context.Context, cursor *string, limit int) ([]*entity.Permission, *string, *string, bool, bool, error) + Update(ctx context.Context, perm *entity.Permission) error + Delete(ctx context.Context, id uuid.UUID) error +} + +// UserRoleRepository and RolePermissionRepository remain unchanged +``` + +- [ ] **Step 5: Build check** + +```bash +go build ./... +``` + +Expected: compile errors in repository implementations (they haven't been updated yet). That's expected. + +- [ ] **Step 6: Commit** + +```bash +git add internal/todo/domain/repository/todo_repository.go internal/user/domain/repository/user_repository.go internal/tenant/domain/repository/tenant.go internal/authorization/domain/repository/authorization_repository.go +git commit -m "feat: update repository interfaces for cursor pagination" +``` + +--- + +### Task 8: Implement cursor pagination in todo repository (GetAll, Search) + +**Files:** +- Modify: `internal/todo/infrastructure/persistence/todo_repository.go` + +- [ ] **Step 1: Rewrite `GetAll` with cursor pagination** + +Replace lines 54-110 (the current `GetAll` method): + +```go +func (r *todoRepository) GetAll(ctx context.Context, cursor *string, limit int) ([]*entity.Todo, *string, *string, bool, bool, error) { + args := []interface{}{} + whereClause := "WHERE deleted_at IS NULL" + + if r.tenantConfig != nil && r.tenantConfig.Enabled { + tenantID := middleware.GetTenantID(ctx) + if tenantID != "" { + whereClause += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tenantID) + } + } + + nextPos := len(args) + 1 + if cursor != nil { + c, err := cursor.Decode(*cursor) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("invalid cursor: %w", err) + } + whereClause += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", nextPos, nextPos+1) + args = append(args, c.Timestamp, c.ID) + nextPos += 2 + } + + dataQuery := fmt.Sprintf("SELECT id, title, description, completed, created_at, updated_at, deleted_at FROM todos %s ORDER BY created_at DESC, id DESC LIMIT $%d", whereClause, nextPos) + countArgs := make([]interface{}, len(args)) + copy(countArgs, args) + queryArgs := append(args, limit+1) + + rows, err := r.db.QueryContext(ctx, dataQuery, queryArgs...) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("query todos: %w", err) + } + defer rows.Close() + + var todos []*entity.Todo + for rows.Next() { + var t entity.Todo + var deletedAt sql.NullTime + if err := rows.Scan(&t.ID, &t.Title, &t.Description, &t.Completed, &t.CreatedAt, &t.UpdatedAt, &deletedAt); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("scan todo: %w", err) + } + if deletedAt.Valid { + t.DeletedAt = &deletedAt.Time + } + todos = append(todos, &t) + } + if err := rows.Err(); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("rows iteration: %w", err) + } + + hasNext := len(todos) > limit + if hasNext { + todos = todos[:limit] + } + + var nextCursor *string + var prevCursor *string + if len(todos) > 0 { + last := todos[len(todos)-1] + nc := cursor.Encode(last.CreatedAt, last.ID) + nextCursor = &nc + + first := todos[0] + pc := cursor.Encode(first.CreatedAt, first.ID) + prevCursor = &pc + } + + hasPrev := cursor != nil + if hasPrev && len(todos) == 0 { + hasPrev = false + } + + return todos, nextCursor, prevCursor, hasNext, hasPrev, nil +} +``` + +Add imports for `cursor` and `fmt`: +```go +"github.com/IDTS-LAB/go-codebase/internal/shared/cursor" +``` + +- [ ] **Step 2: Rewrite `Search` with cursor pagination** + +Replace lines 146-201 (current `Search` method): + +```go +func (r *todoRepository) Search(ctx context.Context, query string, cursorArg *string, limit int) ([]*entity.Todo, *string, *string, bool, bool, error) { + searchPattern := "%" + query + "%" + + args := []interface{}{searchPattern} + whereClause := "WHERE deleted_at IS NULL AND (title ILIKE $1 OR description ILIKE $1)" + nextPos := 2 + + if r.tenantConfig != nil && r.tenantConfig.Enabled { + tenantID := middleware.GetTenantID(ctx) + if tenantID != "" { + whereClause += fmt.Sprintf(" AND tenant_id = $%d", nextPos) + args = append(args, tenantID) + nextPos++ + } + } + + if cursorArg != nil { + c, err := cursor.Decode(*cursorArg) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("invalid cursor: %w", err) + } + whereClause += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", nextPos, nextPos+1) + args = append(args, c.Timestamp, c.ID) + nextPos += 2 + } + + dataQuery := fmt.Sprintf("SELECT id, title, description, completed, created_at, updated_at, deleted_at FROM todos %s ORDER BY created_at DESC, id DESC LIMIT $%d", whereClause, nextPos) + dataArgs := append(args, limit+1) + + rows, err := r.db.QueryContext(ctx, dataQuery, dataArgs...) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("search todos: %w", err) + } + defer rows.Close() + + var todos []*entity.Todo + for rows.Next() { + var t entity.Todo + var deletedAt sql.NullTime + if err := rows.Scan(&t.ID, &t.Title, &t.Description, &t.Completed, &t.CreatedAt, &t.UpdatedAt, &deletedAt); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("scan todo: %w", err) + } + if deletedAt.Valid { + t.DeletedAt = &deletedAt.Time + } + todos = append(todos, &t) + } + if err := rows.Err(); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("rows iteration: %w", err) + } + + hasNext := len(todos) > limit + if hasNext { + todos = todos[:limit] + } + + var nextCursor *string + var prevCursor *string + if len(todos) > 0 { + last := todos[len(todos)-1] + nc := cursor.Encode(last.CreatedAt, last.ID) + nextCursor = &nc + + first := todos[0] + pc := cursor.Encode(first.CreatedAt, first.ID) + prevCursor = &pc + } + + hasPrev := cursorArg != nil + if hasPrev && len(todos) == 0 { + hasPrev = false + } + + return todos, nextCursor, prevCursor, hasNext, hasPrev, nil +} +``` + +- [ ] **Step 3: Build check** + +```bash +go build ./... +``` + +Expected: pass. + +- [ ] **Step 4: Commit** + +```bash +git add internal/todo/infrastructure/persistence/todo_repository.go +git commit -m "feat: implement cursor pagination in todo repository (GetAll, Search)" +``` + +--- + +### Task 9: Implement cursor pagination in user repository (List) + +**Files:** +- Modify: `internal/user/infrastructure/persistence/user_repository.go` + +- [ ] **Step 1: Rewrite `List` with cursor pagination** + +Replace lines 24-80 (the current `List` method): + +```go +func (r *userRepository) List(ctx context.Context, cursorArg *string, limit int) ([]*entity.User, *string, *string, bool, bool, error) { + args := []interface{}{} + whereClause := "WHERE u.deleted_at IS NULL" + + if r.tenantConfig != nil && r.tenantConfig.Enabled { + tenantID := middleware.GetTenantID(ctx) + if tenantID != "" { + whereClause += fmt.Sprintf(" AND u.tenant_id = $%d", len(args)+1) + args = append(args, tenantID) + } + } + + nextPos := len(args) + 1 + if cursorArg != nil { + c, err := cursor.Decode(*cursorArg) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("invalid cursor: %w", err) + } + whereClause += fmt.Sprintf(" AND (u.created_at, u.id) < ($%d, $%d)", nextPos, nextPos+1) + args = append(args, c.Timestamp, c.ID) + nextPos += 2 + } + + dataQuery := fmt.Sprintf("SELECT u.id, u.email, u.name, u.is_active, u.created_at, u.updated_at, u.deleted_at FROM users u %s ORDER BY u.created_at DESC, u.id DESC LIMIT $%d", whereClause, nextPos) + dataArgs := append(args, limit+1) + + rows, err := r.db.QueryContext(ctx, dataQuery, dataArgs...) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + + var users []*entity.User + for rows.Next() { + var u entity.User + var deletedAt sql.NullTime + if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.IsActive, &u.CreatedAt, &u.UpdatedAt, &deletedAt); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("scan user: %w", err) + } + if deletedAt.Valid { + u.DeletedAt = &deletedAt.Time + } + users = append(users, &u) + } + if err := rows.Err(); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("rows iteration: %w", err) + } + + hasNext := len(users) > limit + if hasNext { + users = users[:limit] + } + + var nextCursor *string + var prevCursor *string + if len(users) > 0 { + last := users[len(users)-1] + nc := cursor.Encode(last.CreatedAt, last.ID) + nextCursor = &nc + + first := users[0] + pc := cursor.Encode(first.CreatedAt, first.ID) + prevCursor = &pc + } + + hasPrev := cursorArg != nil + if hasPrev && len(users) == 0 { + hasPrev = false + } + + return users, nextCursor, prevCursor, hasNext, hasPrev, nil +} +``` + +Add imports: +```go +"github.com/IDTS-LAB/go-codebase/internal/shared/cursor" +``` + +- [ ] **Step 2: Build check** + +```bash +go build ./... +``` + +Expected: pass. + +- [ ] **Step 3: Commit** + +```bash +git add internal/user/infrastructure/persistence/user_repository.go +git commit -m "feat: implement cursor pagination in user repository (List)" +``` + +--- + +### Task 10: Implement cursor pagination in tenant repository (List) + +**Files:** +- Modify: `internal/tenant/infrastructure/persistence/tenant_repository.go` + +- [ ] **Step 1: Rewrite `List` with cursor pagination** + +Replace the current `List` method in `internal/tenant/infrastructure/persistence/tenant_repository.go` (around line 66): + +```go +func (r *tenantRepository) List(ctx context.Context, cursorArg *string, limit int) ([]entity.Tenant, *string, *string, bool, bool, error) { + args := []interface{}{} + + nextPos := 1 + query := "SELECT id, name, slug, domain, settings, is_active, created_at, updated_at FROM tenants" + + if cursorArg != nil { + c, err := cursor.Decode(*cursorArg) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("invalid cursor: %w", err) + } + query += fmt.Sprintf(" WHERE (created_at, id) < ($%d, $%d)", nextPos, nextPos+1) + args = append(args, c.Timestamp, c.ID) + nextPos += 2 + } + + query += fmt.Sprintf(" ORDER BY created_at DESC, id DESC LIMIT $%d", nextPos) + dataArgs := append(args, limit+1) + + rows, err := r.db.QueryContext(ctx, query, dataArgs...) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("list tenants: %w", err) + } + defer rows.Close() + + var tenants []entity.Tenant + for rows.Next() { + var t entity.Tenant + if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Domain, &t.Settings, &t.IsActive, &t.CreatedAt, &t.UpdatedAt); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("scan tenant: %w", err) + } + tenants = append(tenants, t) + } + if err := rows.Err(); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("rows iteration: %w", err) + } + + hasNext := len(tenants) > limit + if hasNext { + tenants = tenants[:limit] + } + + var nextCursor *string + var prevCursor *string + if len(tenants) > 0 { + last := tenants[len(tenants)-1] + nc := cursor.Encode(last.CreatedAt, last.ID) + nextCursor = &nc + + first := tenants[0] + pc := cursor.Encode(first.CreatedAt, first.ID) + prevCursor = &pc + } + + hasPrev := cursorArg != nil + if hasPrev && len(tenants) == 0 { + hasPrev = false + } + + return tenants, nextCursor, prevCursor, hasNext, hasPrev, nil +} +``` + +Add imports: +```go +"github.com/IDTS-LAB/go-codebase/internal/shared/cursor" +``` + +Remove unused `sqlc` import if `CountTenants` and sqlc `ListTenants` are no longer used. + +- [ ] **Step 2: Build check** + +```bash +go build ./... +``` + +Expected: pass. + +- [ ] **Step 3: Commit** + +```bash +git add internal/tenant/infrastructure/persistence/tenant_repository.go +git commit -m "feat: implement cursor pagination in tenant repository (List)" +``` + +--- + +### Task 11: Implement cursor pagination in authz repositories (role/permission GetAll) + +**Files:** +- Modify: `internal/authorization/infrastructure/persistence/role_repository.go` +- Modify: `internal/authorization/infrastructure/persistence/permission_repository.go` + +- [ ] **Step 1: Rewrite `roleRepository.GetAll`** + +Replace the current `GetAll` in `internal/authorization/infrastructure/persistence/role_repository.go` (lines 66-122): + +```go +func (r *roleRepository) GetAll(ctx context.Context, cursorArg *string, limit int) ([]*entity.Role, *string, *string, bool, bool, error) { + args := []interface{}{} + whereClause := "WHERE deleted_at IS NULL" + + if r.tenantConfig != nil && r.tenantConfig.Enabled { + tenantID := middleware.GetTenantID(ctx) + if tenantID != "" { + whereClause += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tenantID) + } + } + + nextPos := len(args) + 1 + if cursorArg != nil { + c, err := cursor.Decode(*cursorArg) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("invalid cursor: %w", err) + } + whereClause += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", nextPos, nextPos+1) + args = append(args, c.Timestamp, c.ID) + nextPos += 2 + } + + dataQuery := fmt.Sprintf("SELECT id, name, description, created_at, updated_at, deleted_at FROM roles %s ORDER BY created_at DESC, id DESC LIMIT $%d", whereClause, nextPos) + dataArgs := append(args, limit+1) + + rows, err := r.db.QueryContext(ctx, dataQuery, dataArgs...) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("query roles: %w", err) + } + defer rows.Close() + + var roles []*entity.Role + for rows.Next() { + var rl entity.Role + var deletedAt sql.NullTime + if err := rows.Scan(&rl.ID, &rl.Name, &rl.Description, &rl.CreatedAt, &rl.UpdatedAt, &deletedAt); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("scan role: %w", err) + } + if deletedAt.Valid { + rl.DeletedAt = &deletedAt.Time + } + roles = append(roles, &rl) + } + if err := rows.Err(); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("rows iteration: %w", err) + } + + hasNext := len(roles) > limit + if hasNext { + roles = roles[:limit] + } + + var nextCursor *string + var prevCursor *string + if len(roles) > 0 { + last := roles[len(roles)-1] + nc := cursor.Encode(last.CreatedAt, last.ID) + nextCursor = &nc + + first := roles[0] + pc := cursor.Encode(first.CreatedAt, first.ID) + prevCursor = &pc + } + + hasPrev := cursorArg != nil + if hasPrev && len(roles) == 0 { + hasPrev = false + } + + return roles, nextCursor, prevCursor, hasNext, hasPrev, nil +} +``` + +Add imports: +```go +"github.com/IDTS-LAB/go-codebase/internal/shared/cursor" +``` + +- [ ] **Step 2: Rewrite `permissionRepository.GetAll`** + +Same pattern as role — replace the current `GetAll` in `internal/authorization/infrastructure/persistence/permission_repository.go` (lines 68-124) with the same cursor pagination pattern, adjusting the SELECT columns to include `resource, action`: + +```go +func (r *permissionRepository) GetAll(ctx context.Context, cursorArg *string, limit int) ([]*entity.Permission, *string, *string, bool, bool, error) { + args := []interface{}{} + whereClause := "WHERE deleted_at IS NULL" + + if r.tenantConfig != nil && r.tenantConfig.Enabled { + tenantID := middleware.GetTenantID(ctx) + if tenantID != "" { + whereClause += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tenantID) + } + } + + nextPos := len(args) + 1 + if cursorArg != nil { + c, err := cursor.Decode(*cursorArg) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("invalid cursor: %w", err) + } + whereClause += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", nextPos, nextPos+1) + args = append(args, c.Timestamp, c.ID) + nextPos += 2 + } + + dataQuery := fmt.Sprintf("SELECT id, name, description, resource, action, created_at, updated_at, deleted_at FROM permissions %s ORDER BY created_at DESC, id DESC LIMIT $%d", whereClause, nextPos) + dataArgs := append(args, limit+1) + + rows, err := r.db.QueryContext(ctx, dataQuery, dataArgs...) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("query permissions: %w", err) + } + defer rows.Close() + + var perms []*entity.Permission + for rows.Next() { + var p entity.Permission + var deletedAt sql.NullTime + if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Resource, &p.Action, &p.CreatedAt, &p.UpdatedAt, &deletedAt); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("scan permission: %w", err) + } + if deletedAt.Valid { + p.DeletedAt = &deletedAt.Time + } + perms = append(perms, &p) + } + if err := rows.Err(); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("rows iteration: %w", err) + } + + hasNext := len(perms) > limit + if hasNext { + perms = perms[:limit] + } + + var nextCursor *string + var prevCursor *string + if len(perms) > 0 { + last := perms[len(perms)-1] + nc := cursor.Encode(last.CreatedAt, last.ID) + nextCursor = &nc + + first := perms[0] + pc := cursor.Encode(first.CreatedAt, first.ID) + prevCursor = &pc + } + + hasPrev := cursorArg != nil + if hasPrev && len(perms) == 0 { + hasPrev = false + } + + return perms, nextCursor, prevCursor, hasNext, hasPrev, nil +} +``` + +- [ ] **Step 3: Build check** + +```bash +go build ./... +``` + +Expected: pass. + +- [ ] **Step 4: Commit** + +```bash +git add internal/authorization/infrastructure/persistence/role_repository.go internal/authorization/infrastructure/persistence/permission_repository.go +git commit -m "feat: implement cursor pagination in authz repositories (role, permission GetAll)" +``` + +--- + +### Task 12: Update todo domain service for cursor pagination + +**Files:** +- Modify: `internal/todo/domain/service/todo_domain_service.go` + +- [ ] **Step 1: Update `ListTodos` and `SearchTodos` signatures** + +Replace lines 46-48 and 88-89: + +```go +func (s *TodoDomainService) ListTodos(ctx context.Context, cursor *string, limit int) ([]*entity.Todo, *string, *string, bool, bool, error) { + return s.repo.GetAll(ctx, cursor, limit) +} + +func (s *TodoDomainService) SearchTodos(ctx context.Context, query string, cursor *string, limit int) ([]*entity.Todo, *string, *string, bool, bool, error) { + return s.repo.Search(ctx, query, cursor, limit) +} +``` + +- [ ] **Step 2: Build check** + +```bash +go build ./... +``` + +Expected: pass. + +- [ ] **Step 3: Commit** + +```bash +git add internal/todo/domain/service/todo_domain_service.go +git commit -m "feat: update todo domain service for cursor pagination signatures" +``` + +--- + +### Task 13: Update CQRS query handlers for cursor pagination + +**Files:** +- Modify: `internal/todo/application/query/list_todos.go` +- Modify: `internal/todo/application/query/search_todos.go` +- Modify: `internal/user/application/query/list_users.go` +- Modify: `internal/tenant/application/query/list_tenants.go` +- Modify: `internal/authorization/application/query/list_roles.go` +- Modify: `internal/authorization/application/query/list_permissions.go` + +- [ ] **Step 1: Update `ListTodosHandler`** + +Replace `internal/todo/application/query/list_todos.go`: + +```go +package query + +import ( + "context" + "github.com/IDTS-LAB/go-codebase/internal/todo/domain/service" +) + +type ListTodosQuery struct { + Cursor *string + Limit int +} + +type ListTodosResult struct { + Todos []any + NextCursor *string + PrevCursor *string + HasNext bool + HasPrev bool + Limit int +} + +type ListTodosHandler struct { + domainSvc *service.TodoDomainService +} + +func NewListTodosHandler(domainSvc *service.TodoDomainService) *ListTodosHandler { + return &ListTodosHandler{domainSvc: domainSvc} +} + +func (h *ListTodosHandler) Handle(ctx context.Context, q any) (any, error) { + query := q.(ListTodosQuery) + todos, nextCursor, prevCursor, hasNext, hasPrev, err := h.domainSvc.ListTodos(ctx, query.Cursor, query.Limit) + if err != nil { + return nil, err + } + return ListTodosResult{ + Todos: todos, + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + Limit: query.Limit, + }, nil +} +``` + +- [ ] **Step 2: Update `SearchTodosHandler`** + +Replace `internal/todo/application/query/search_todos.go`: + +```go +package query + +import ( + "context" + "github.com/IDTS-LAB/go-codebase/internal/todo/domain/service" +) + +type SearchTodosQuery struct { + Query string + Cursor *string + Limit int +} + +type SearchTodosResult struct { + Todos []any + NextCursor *string + PrevCursor *string + HasNext bool + HasPrev bool + Limit int +} + +type SearchTodosHandler struct { + domainSvc *service.TodoDomainService +} + +func NewSearchTodosHandler(domainSvc *service.TodoDomainService) *SearchTodosHandler { + return &SearchTodosHandler{domainSvc: domainSvc} +} + +func (h *SearchTodosHandler) Handle(ctx context.Context, q any) (any, error) { + query := q.(SearchTodosQuery) + todos, nextCursor, prevCursor, hasNext, hasPrev, err := h.domainSvc.SearchTodos(ctx, query.Query, query.Cursor, query.Limit) + if err != nil { + return nil, err + } + return SearchTodosResult{ + Todos: todos, + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + Limit: query.Limit, + }, nil +} +``` + +- [ ] **Step 3: Update `ListUsersHandler`** + +Replace `internal/user/application/query/list_users.go`: + +```go +package query + +import ( + "context" + authEntity "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/user/domain/repository" +) + +type ListUsersQuery struct { + Cursor *string + Limit int +} + +type ListUsersResult struct { + Users []*authEntity.User + NextCursor *string + PrevCursor *string + HasNext bool + HasPrev bool + Limit int +} + +type ListUsersHandler struct { + repo repository.UserRepository +} + +func NewListUsersHandler(repo repository.UserRepository) *ListUsersHandler { + return &ListUsersHandler{repo: repo} +} + +func (h *ListUsersHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(ListUsersQuery) + users, nextCursor, prevCursor, hasNext, hasPrev, err := h.repo.List(ctx, q.Cursor, q.Limit) + if err != nil { + return nil, err + } + return ListUsersResult{ + Users: users, + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + Limit: q.Limit, + }, nil +} +``` + +- [ ] **Step 4: Update `ListTenantsHandler`** + +Replace `internal/tenant/application/query/list_tenants.go`: + +```go +package query + +import ( + "context" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/dto" + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/repository" +) + +type ListTenantsQuery struct { + Cursor *string + Limit int +} + +type ListTenantsHandler struct { + repo repository.TenantRepository +} + +func NewListTenantsHandler(repo repository.TenantRepository) *ListTenantsHandler { + return &ListTenantsHandler{repo: repo} +} + +func (h *ListTenantsHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(ListTenantsQuery) + tenants, nextCursor, prevCursor, hasNext, hasPrev, err := h.repo.List(ctx, q.Cursor, q.Limit) + if err != nil { + return nil, err + } + + responses := make([]dto.TenantResponse, len(tenants)) + for i, t := range tenants { + responses[i] = dto.TenantResponse{ + ID: t.ID.String(), + Name: t.Name, + Slug: t.Slug, + Domain: t.Domain, + Settings: t.Settings, + IsActive: t.IsActive, + CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + + return dto.TenantListResponse{ + Tenants: responses, + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + Limit: q.Limit, + }, nil +} +``` + +- [ ] **Step 5: Update `ListRolesHandler`** + +Replace `internal/authorization/application/query/list_roles.go`: + +```go +package query + +import ( + "context" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" +) + +type ListRolesQuery struct { + Cursor *string + Limit int +} + +type ListRolesResult struct { + Roles []*entity.Role + NextCursor *string + PrevCursor *string + HasNext bool + HasPrev bool +} + +type ListRolesHandler struct { + roleRepo repository.RoleRepository +} + +func NewListRolesHandler(roleRepo repository.RoleRepository) *ListRolesHandler { + return &ListRolesHandler{roleRepo: roleRepo} +} + +func (h *ListRolesHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(ListRolesQuery) + roles, nextCursor, prevCursor, hasNext, hasPrev, err := h.roleRepo.GetAll(ctx, q.Cursor, q.Limit) + if err != nil { + return nil, err + } + return ListRolesResult{ + Roles: roles, + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + }, nil +} +``` + +- [ ] **Step 6: Update `ListPermissionsHandler`** + +Replace `internal/authorization/application/query/list_permissions.go`: + +```go +package query + +import ( + "context" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" +) + +type ListPermissionsQuery struct { + Cursor *string + Limit int +} + +type ListPermissionsResult struct { + Permissions []*entity.Permission + NextCursor *string + PrevCursor *string + HasNext bool + HasPrev bool +} + +type ListPermissionsHandler struct { + permRepo repository.PermissionRepository +} + +func NewListPermissionsHandler(permRepo repository.PermissionRepository) *ListPermissionsHandler { + return &ListPermissionsHandler{permRepo: permRepo} +} + +func (h *ListPermissionsHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(ListPermissionsQuery) + permissions, nextCursor, prevCursor, hasNext, hasPrev, err := h.permRepo.GetAll(ctx, q.Cursor, q.Limit) + if err != nil { + return nil, err + } + return ListPermissionsResult{ + Permissions: permissions, + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + }, nil +} +``` + +- [ ] **Step 7: Update `TenantListResponse` DTO** + +Add cursor fields to `internal/tenant/application/dto/tenant.go`: + +```go +type TenantListResponse struct { + Tenants []TenantResponse `json:"tenants"` + NextCursor *string `json:"next_cursor,omitempty"` + PrevCursor *string `json:"prev_cursor,omitempty"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` + Limit int `json:"limit"` +} +``` + +- [ ] **Step 8: Build check** + +```bash +go build ./... +``` + +Expected: pass (some compilation errors remaining in http handlers, which will be fixed next). + +- [ ] **Step 9: Commit** + +```bash +git add internal/todo/application/query/ internal/user/application/query/ internal/tenant/application/query/ internal/tenant/application/dto/ internal/authorization/application/query/ +git commit -m "feat: update CQRS query handlers for cursor pagination" +``` + +--- + +### Task 14: Update HTTP handlers for cursor pagination + +**Files:** +- Modify: `internal/todo/interfaces/http/handlers.go` +- Modify: `internal/user/interfaces/http/handler.go` +- Modify: `internal/tenant/interfaces/http/handlers.go` +- Modify: `internal/authorization/interfaces/http/handlers.go` + +- [ ] **Step 1: Update `TodoHandler.ListTodos` and `SearchTodos`** + +Replace lines 76-95 (`ListTodos`) and 243-264 (`SearchTodos`) in `internal/todo/interfaces/http/handlers.go`: + +```go +func (h *Handler) ListTodos(w http.ResponseWriter, r *http.Request) { + cursorStr := r.URL.Query().Get("cursor") + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 { + limit = n + } + } + + var cursor *string + if cursorStr != "" { + cursor = &cursorStr + } + + resp, err := h.queryBus.Ask(r.Context(), query.ListTodosQuery{Cursor: cursor, Limit: limit}) + if err != nil { + utils.MapError(w, err) + return + } + result := resp.(query.ListTodosResult) + utils.RespondCursorPaginated(w, result.Todos, result.NextCursor, result.PrevCursor, result.HasNext, result.HasPrev, result.Limit) +} + +func (h *Handler) SearchTodos(w http.ResponseWriter, r *http.Request) { + queryStr := r.URL.Query().Get("q") + if queryStr == "" { + utils.RespondBadRequest(w, "search query is required") + return + } + cursorStr := r.URL.Query().Get("cursor") + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 { + limit = n + } + } + + var cursor *string + if cursorStr != "" { + cursor = &cursorStr + } + + resp, err := h.queryBus.Ask(r.Context(), query.SearchTodosQuery{Query: queryStr, Cursor: cursor, Limit: limit}) + if err != nil { + utils.MapError(w, err) + return + } + result := resp.(query.SearchTodosResult) + utils.RespondCursorPaginated(w, result.Todos, result.NextCursor, result.PrevCursor, result.HasNext, result.HasPrev, result.Limit) +} +``` + +Add `"strconv"` and `"github.com/IDTS-LAB/go-codebase/internal/todo/application/query"` to imports. + +- [ ] **Step 2: Update `UserHandler.List`** + +Replace lines 72-99 in `internal/user/interfaces/http/handler.go`: + +```go +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + cursorStr := r.URL.Query().Get("cursor") + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 { + limit = n + } + } + + var cursor *string + if cursorStr != "" { + cursor = &cursorStr + } + + resp, err := h.queryBus.Ask(r.Context(), query.ListUsersQuery{Cursor: cursor, Limit: limit}) + if err != nil { + utils.MapError(w, err) + return + } + + result := resp.(query.ListUsersResult) + usersResp := make([]UserResponse, len(result.Users)) + for i, u := range result.Users { + usersResp[i] = userToResponse(u) + } + + utils.RespondCursorPaginated(w, usersResp, result.NextCursor, result.PrevCursor, result.HasNext, result.HasPrev, result.Limit) +} +``` + +- [ ] **Step 3: Update `TenantHandler.List`** + +Replace lines 53-72 in `internal/tenant/interfaces/http/handlers.go`: + +```go +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + cursorStr := r.URL.Query().Get("cursor") + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 { + limit = n + } + } + + var cursor *string + if cursorStr != "" { + cursor = &cursorStr + } + + resp, err := h.queryBus.Ask(r.Context(), query.ListTenantsQuery{Cursor: cursor, Limit: limit}) + if err != nil { + utils.MapError(w, err) + return + } + listResp := resp.(dto.TenantListResponse) + utils.RespondCursorPaginated(w, listResp.Tenants, listResp.NextCursor, listResp.PrevCursor, listResp.HasNext, listResp.HasPrev, listResp.Limit) +} +``` + +- [ ] **Step 4: Update `AuthzHandler.ListRoles` and `ListPermissions`** + +Replace lines 69-84 and 199-214 in `internal/authorization/interfaces/http/handlers.go`: + +```go +func (h *Handler) ListRoles(w http.ResponseWriter, r *http.Request) { + cursorStr := r.URL.Query().Get("cursor") + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 { + limit = n + } + } + + var cursor *string + if cursorStr != "" { + cursor = &cursorStr + } + + resp, err := h.queryBus.Ask(r.Context(), query.ListRolesQuery{Cursor: cursor, Limit: limit}) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error()) + return + } + result := resp.(query.ListRolesResult) + utils.RespondCursorPaginated(w, result.Roles, result.NextCursor, result.PrevCursor, result.HasNext, result.HasPrev, limit) +} + +func (h *Handler) ListPermissions(w http.ResponseWriter, r *http.Request) { + cursorStr := r.URL.Query().Get("cursor") + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 { + limit = n + } + } + + var cursor *string + if cursorStr != "" { + cursor = &cursorStr + } + + resp, err := h.queryBus.Ask(r.Context(), query.ListPermissionsQuery{Cursor: cursor, Limit: limit}) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error()) + return + } + result := resp.(query.ListPermissionsResult) + utils.RespondCursorPaginated(w, result.Permissions, result.NextCursor, result.PrevCursor, result.HasNext, result.HasPrev, limit) +} +``` + +- [ ] **Step 5: Build check** + +```bash +go build ./... +``` + +Expected: pass. + +- [ ] **Step 6: Commit** + +```bash +git add internal/todo/interfaces/http/handlers.go internal/user/interfaces/http/handler.go internal/tenant/interfaces/http/handlers.go internal/authorization/interfaces/http/handlers.go +git commit -m "feat: update HTTP handlers for cursor pagination" +``` + +--- + +### Task 15: Add migration for casbin_rule table + +**Files:** +- Create: `migrations/010_add_casbin_rule_table.sql` + +- [ ] **Step 1: Create migration** + +**File:** `migrations/010_add_casbin_rule_table.sql` + +```sql +-- +goose Up +CREATE TABLE casbin_rule ( + id SERIAL PRIMARY KEY, + ptype VARCHAR(100) NOT NULL, + v0 VARCHAR(255), + v1 VARCHAR(255), + v2 VARCHAR(255), + v3 VARCHAR(255), + v4 VARCHAR(255), + v5 VARCHAR(255) +); + +CREATE INDEX idx_casbin_rule_ptype ON casbin_rule(ptype); +CREATE INDEX idx_casbin_rule_v0 ON casbin_rule(v0); + +-- +goose Down +DROP TABLE IF EXISTS casbin_rule; +``` + +- [ ] **Step 2: Commit** + +```bash +git add migrations/010_add_casbin_rule_table.sql +git commit -m "feat: add casbin_rule table migration" +``` + +--- + +### Task 16: Implement custom Casbin adapter + +**Files:** +- Rewrite: `internal/authorization/infrastructure/casbin/adapter.go` + +**Interfaces:** +- Consumes: `*sql.DB`, `casbin/model.Model` +- Produces: `*Adapter` implementing `persist.Adapter` (LoadPolicy, SavePolicy, AddPolicy, RemovePolicy, RemoveFilteredPolicy) + +- [ ] **Step 1: Rewrite adapter.go** + +Replace entire `internal/authorization/infrastructure/casbin/adapter.go`: + +```go +package casbin + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/casbin/casbin/v2/model" + "github.com/casbin/casbin/v2/persist" +) + +type Adapter struct { + db *sql.DB +} + +func NewAdapter(db *sql.DB) *Adapter { + return &Adapter{db: db} +} + +func (a *Adapter) LoadPolicy(model model.Model) error { + rows, err := a.db.QueryContext(context.Background(), + "SELECT ptype, v0, v1, v2, v3, v4, v5 FROM casbin_rule") + if err != nil { + return fmt.Errorf("load casbin policies: %w", err) + } + defer rows.Close() + + for rows.Next() { + var ptype string + var v0, v1, v2, v3, v4, v5 sql.NullString + if err := rows.Scan(&ptype, &v0, &v1, &v2, &v3, &v4, &v5); err != nil { + return fmt.Errorf("scan casbin rule: %w", err) + } + line := persist.ValuesToSlice([]string{ptype, v0.String, v1.String, v2.String, v3.String, v4.String, v5.String}) + persist.LoadPolicyLine(line, model) + } + return rows.Err() +} + +func (a *Adapter) SavePolicy(model model.Model) error { + // Not needed — policies are synced via AddPolicy/RemovePolicy in command handlers + return nil +} + +func (a *Adapter) AddPolicy(sec string, ptype string, rule []string) error { + args := make([]interface{}, 7) + args[0] = ptype + for i := 1; i <= 6; i++ { + if i-1 < len(rule) { + args[i] = rule[i-1] + } else { + args[i] = "" + } + } + _, err := a.db.ExecContext(context.Background(), + "INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ($1, $2, $3, $4, $5, $6, $7)", + args...) + if err != nil { + return fmt.Errorf("add casbin policy: %w", err) + } + return nil +} + +func (a *Adapter) RemovePolicy(sec string, ptype string, rule []string) error { + query := "DELETE FROM casbin_rule WHERE ptype = $1" + args := []interface{}{ptype} + + for i, r := range rule { + pos := i + 2 + query += fmt.Sprintf(" AND v%d = $%d", i, pos) + args = append(args, r) + } + + _, err := a.db.ExecContext(context.Background(), query, args...) + if err != nil { + return fmt.Errorf("remove casbin policy: %w", err) + } + return nil +} + +func (a *Adapter) RemoveFilteredPolicy(sec string, ptype string, fieldIndex int, fieldValues ...string) error { + query := "DELETE FROM casbin_rule WHERE ptype = $1" + args := []interface{}{ptype} + + for i, fv := range fieldValues { + if fv == "" { + continue + } + vi := fieldIndex + i + query += fmt.Sprintf(" AND v%d = $%d", vi, len(args)+1) + args = append(args, fv) + } + + if len(args) == 1 { + return nil // no filter values provided + } + + _, err := a.db.ExecContext(context.Background(), query, args...) + if err != nil { + return fmt.Errorf("remove filtered casbin policy: %w", err) + } + return nil +} + +func (a *Adapter) AddPolicies(sec string, ptype string, rules [][]string) error { + tx, err := a.db.Begin() + if err != nil { + return fmt.Errorf("begin tx for add policies: %w", err) + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(context.Background(), + "INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ($1, $2, $3, $4, $5, $6, $7)") + if err != nil { + return fmt.Errorf("prepare add policies: %w", err) + } + defer stmt.Close() + + for _, rule := range rules { + args := make([]interface{}, 7) + args[0] = ptype + for i := 1; i <= 6; i++ { + if i-1 < len(rule) { + args[i] = rule[i-1] + } else { + args[i] = "" + } + } + if _, err := stmt.Exec(args...); err != nil { + return fmt.Errorf("add policy in batch: %w", err) + } + } + + return tx.Commit() +} + +func (a *Adapter) RemovePolicies(sec string, ptype string, rules [][]string) error { + tx, err := a.db.Begin() + if err != nil { + return fmt.Errorf("begin tx for remove policies: %w", err) + } + defer tx.Rollback() + + for _, rule := range rules { + query := "DELETE FROM casbin_rule WHERE ptype = $1" + args := []interface{}{ptype} + for i, r := range rule { + pos := i + 2 + query += fmt.Sprintf(" AND v%d = $%d", i, pos) + args = append(args, r) + } + if _, err := tx.ExecContext(context.Background(), query, args...); err != nil { + return fmt.Errorf("remove policy in batch: %w", err) + } + } + + return tx.Commit() +} + +var _ persist.Adapter = (*Adapter)(nil) +``` + +- [ ] **Step 2: Build check** + +```bash +go build ./... +``` + +Expected: pass. + +- [ ] **Step 3: Commit** + +```bash +git add internal/authorization/infrastructure/casbin/adapter.go +git commit -m "feat: implement custom Casbin adapter for casbin_rule table" +``` + +--- + +### Task 17: Update Casbin enforcer to use adapter + +**Files:** +- Modify: `internal/authorization/infrastructure/casbin/enforcer.go` +- Delete: `internal/authorization/infrastructure/casbin/model.conf` (keep as-is) +- Create/Modify wire dependency for `Adapter` + +- [ ] **Step 1: Update `Enforcer` to use adapter instead of PolicyLoader** + +Replace `internal/authorization/infrastructure/casbin/enforcer.go`: + +```go +package casbin + +import ( + "context" + _ "embed" + "fmt" + + "github.com/casbin/casbin/v2" + "github.com/casbin/casbin/v2/model" + "github.com/google/uuid" + "go.uber.org/fx" +) + +//go:embed model.conf +var modelConf string + +var Module = fx.Module("casbin", fx.Provide( + NewAdapter, + NewEnforcer, +)) + +type Enforcer struct { + enforcer *casbin.CachedEnforcer + adapter *Adapter +} + +func NewEnforcer(adapter *Adapter) (*Enforcer, error) { + m, err := model.NewModelFromString(modelConf) + if err != nil { + return nil, fmt.Errorf("parse casbin model: %w", err) + } + + enforcer, err := casbin.NewCachedEnforcer(m, adapter) + if err != nil { + return nil, fmt.Errorf("create casbin enforcer: %w", err) + } + + e := &Enforcer{ + enforcer: enforcer, + adapter: adapter, + } + + if err := e.ReloadPolicies(); err != nil { + return nil, fmt.Errorf("load initial policies: %w", err) + } + + return e, nil +} + +func (e *Enforcer) ReloadPolicies() error { + if err := e.enforcer.LoadPolicy(); err != nil { + return fmt.Errorf("reload policies: %w", err) + } + return nil +} + +func (e *Enforcer) ReloadUserPolicies(ctx context.Context, userID uuid.UUID) error { + subject := userID.String() + _, err := e.enforcer.RemoveFilteredPolicy(0, subject) + if err != nil { + return fmt.Errorf("remove user policies: %w", err) + } + + policies, err := loadUserPolicies(ctx, e.adapter.db, userID) + if err != nil { + return fmt.Errorf("load user policies from db: %w", err) + } + + for _, p := range policies { + if _, err := e.enforcer.AddPolicy(p.Subject, p.Object, p.Action); err != nil { + return fmt.Errorf("add user policy: %w", err) + } + } + + return nil +} + +func (e *Enforcer) Enforce(userID uuid.UUID, resource, action string) (bool, error) { + return e.enforcer.Enforce(userID.String(), resource, action) +} +``` + +- [ ] **Step 2: Add `loadUserPolicies` helper** + +Add to the same file after the module var block: + +```go +func loadUserPolicies(ctx context.Context, db *sql.DB, userID uuid.UUID) ([]Policy, error) { + query := ` + SELECT ur.user_id::text, p.resource, p.action + FROM user_roles ur + JOIN role_permissions rp ON ur.role_id = rp.role_id + JOIN permissions p ON rp.permission_id = p.id + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = $1 AND r.deleted_at IS NULL AND p.deleted_at IS NULL` + + rows, err := db.QueryContext(ctx, query, userID) + if err != nil { + return nil, fmt.Errorf("load user policies: %w", err) + } + defer rows.Close() + + var policies []Policy + for rows.Next() { + var pol Policy + if err := rows.Scan(&pol.Subject, &pol.Object, &pol.Action); err != nil { + return nil, fmt.Errorf("scan policy: %w", err) + } + policies = append(policies, pol) + } + return policies, rows.Err() +} +``` + +Add `"database/sql"` to imports. + +- [ ] **Step 3: Build check** + +```bash +go build ./... +``` + +Expected: pass. + +- [ ] **Step 4: Commit** + +```bash +git add internal/authorization/infrastructure/casbin/enforcer.go +git commit -m "feat: update Casbin enforcer to use custom adapter" +``` + +--- + +### Task 18: Sync casbin_rule on RBAC mutations + +**Files:** +- Modify: `internal/authorization/application/command/assign_role.go` +- Modify: `internal/authorization/application/command/unassign_role.go` +- Modify: `internal/authorization/application/command/assign_permission.go` +- Modify: `internal/authorization/application/command/unassign_permission.go` + +Each command handler needs to call `enforcer.ReloadUserPolicies()` or `enforcer.ReloadPolicies()` after the DB mutation commits. + +- [ ] **Step 1: Update `AssignRoleCommandHandler`** + +In `internal/authorization/application/command/assign_role.go`, after `userRoleRepo.Assign(...)` succeeds, add: + +```go +if err := h.enforcer.ReloadUserPolicies(ctx, cmd.UserID); err != nil { + return nil, fmt.Errorf("reload user policies after role assign: %w", err) +} +``` + +- [ ] **Step 2: Update `UnassignRoleCommandHandler`** + +Same pattern — after `userRoleRepo.Remove(...)`, add: + +```go +if err := h.enforcer.ReloadUserPolicies(ctx, cmd.UserID); err != nil { + return nil, fmt.Errorf("reload user policies after role unassign: %w", err) +} +``` + +- [ ] **Step 3: Update `AssignPermissionCommandHandler`** + +After `rolePermRepo.Assign(...)`, call full reload since multiple users may be affected: + +```go +if err := h.enforcer.ReloadPolicies(); err != nil { + return nil, fmt.Errorf("reload policies after permission assign: %w", err) +} +``` + +- [ ] **Step 4: Update `UnassignPermissionCommandHandler`** + +Same — after `rolePermRepo.Remove(...)`, call full reload: + +```go +if err := h.enforcer.ReloadPolicies(); err != nil { + return nil, fmt.Errorf("reload policies after permission unassign: %w", err) +} +``` + +- [ ] **Step 5: Build check** + +```bash +go build ./... +``` + +Expected: pass. + +- [ ] **Step 6: Commit** + +```bash +git add internal/authorization/application/command/ +git commit -m "feat: sync casbin_rule policies on RBAC mutations" +``` + +--- + +### Task 19: Cleanup unused imports and verify build + +**Files:** +- Check all modified files for unused imports +- Run full build and test + +- [ ] **Step 1: Run full build** + +```bash +go build ./... +``` + +Expected: pass with no errors. + +- [ ] **Step 2: Run go vet** + +```bash +go vet ./... +``` + +Expected: pass. + +- [ ] **Step 3: Run tests** + +```bash +go test ./... -count=1 2>&1 | tail -30 +``` + +Expected: all existing tests pass. Note: some tests may reference old signatures (e.g., mock repos) — fix any test compilation errors. + +- [ ] **Step 4: Commit any fixups** + +```bash +git add -A +git commit -m "chore: fix tests and unused imports after cursor/sqlc/casbin changes" +``` diff --git a/docs/superpowers/plans/2026-07-14-nats-event-bus.md b/docs/superpowers/plans/2026-07-14-nats-event-bus.md new file mode 100644 index 0000000..8fc8383 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-nats-event-bus.md @@ -0,0 +1,755 @@ +# NATS Event Bus Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Make the EventBus switchable between in-memory and NATS JetStream via `events.driver` config, with persistent streams and at-least-once delivery. + +**Architecture:** JetStream stream `"events"` persists messages. `NATSEventBus` publishes via `JetStream.Publish()` and subscribes via push consumer with manual ACK. On handler success → `Ack()`, on failure → `Nak()` (infinite retry). All instances share a queue group so only one worker receives each message. + +**Tech Stack:** Go 1.25, NATS v2 with JetStream, Uber Fx, `encoding/json` + +## Global Constraints + +- `events.driver: memory` is default (existing behavior unchanged) +- `events.driver: nats` uses `NATSEventBus` with JetStream +- NATS server needs `-js` flag in docker-compose +- Type registry maps event type strings to `func() interface{}` factory functions +- All existing handlers remain unchanged (same `events.EventBus` interface) +- Auth event structs get JSON tags for NATS serialization +- JetStream stream `"events"` with subjects `events.>`, file storage, interest retention +- Push consumer `"event-bus"` with queue group, explicit ACK, infinite max deliver, 30s ack wait +- Handler error → Nak (retries indefinitely), success → Ack + +--- + +### Task 1: Config — EventsConfig + Extended NATSConfig + +**Files:** +- Modify: `internal/shared/config/config.go` +- Modify: `configs/config.yaml` + +**Interfaces:** +- Produces: `cfg.Events.Driver string` available for module wiring +- Produces: `cfg.NATS.Stream` and `cfg.NATS.Consumer` for JetStream setup + +- [x] **Step 1: Add EventsConfig and StreamConfig/ConsumerConfig** + +In `internal/shared/config/config.go`, before `func New()` add: +```go +type EventsConfig struct { + Driver string `koanf:"driver"` +} + +type StreamConfig struct { + Name string `koanf:"name"` + Subjects []string `koanf:"subjects"` + Storage string `koanf:"storage"` + Retention string `koanf:"retention"` +} + +type ConsumerConfig struct { + DurableName string `koanf:"durable_name"` + DeliverGroup string `koanf:"deliver_group"` + AckPolicy string `koanf:"ack_policy"` + MaxDeliver int `koanf:"max_deliver"` + AckWait int `koanf:"ack_wait"` +} +``` + +Update `Config` struct to add `Events` field: +```go +Events EventsConfig `koanf:"events"` +``` + +Update `NATSConfig` to add Stream and Consumer: +```go +type NATSConfig struct { + URL string `koanf:"url"` + DebugEndpoint bool `koanf:"debug_endpoint"` + Stream StreamConfig `koanf:"stream"` + Consumer ConsumerConfig `koanf:"consumer"` +} +``` + +Add defaults in `setDefaults()`: +```go +if cfg.Events.Driver == "" { + cfg.Events.Driver = "memory" +} +if cfg.NATS.Stream.Name == "" { + cfg.NATS.Stream.Name = "events" +} +if cfg.NATS.Stream.Storage == "" { + cfg.NATS.Stream.Storage = "file" +} +if cfg.NATS.Stream.Retention == "" { + cfg.NATS.Stream.Retention = "interest" +} +if cfg.NATS.Consumer.DurableName == "" { + cfg.NATS.Consumer.DurableName = "event-bus" +} +if cfg.NATS.Consumer.DeliverGroup == "" { + cfg.NATS.Consumer.DeliverGroup = "event-bus" +} +if cfg.NATS.Consumer.AckPolicy == "" { + cfg.NATS.Consumer.AckPolicy = "explicit" +} +if cfg.NATS.Consumer.MaxDeliver == 0 { + cfg.NATS.Consumer.MaxDeliver = -1 +} +if cfg.NATS.Consumer.AckWait == 0 { + cfg.NATS.Consumer.AckWait = 30 +} +``` + +- [x] **Step 2: Add events and nats stream/consumer to YAML** + +In `configs/config.yaml`, after the email section, add: +```yaml +# ============================================================================= +# Event Bus +# ============================================================================= +events: + driver: memory +``` + +Update the nats section: +```yaml +# ============================================================================= +# NATS +# ============================================================================= +nats: + url: nats://localhost:4222 + debug_endpoint: false + stream: + name: events + subjects: + - events.> + storage: file + retention: interest + consumer: + durable_name: event-bus + deliver_group: event-bus + ack_policy: explicit + max_deliver: -1 + ack_wait: 30 +``` + +- [x] **Step 3: Build and commit** + +```bash +go build ./... +git add internal/shared/config/config.go configs/config.yaml +git commit -m "feat: add events config, NATS JetStream stream/consumer config" +``` + +--- + +### Task 2: Auth Event JSON Tags + +**Files:** +- Modify: `internal/authentication/domain/event/auth_events.go` + +- [x] **Step 1: Add JSON tags** + +In `internal/authentication/domain/event/auth_events.go`: +```go +type UserRegistered struct { + Email string `json:"email"` + Name string `json:"name"` + VerificationToken string `json:"verification_token"` +} + +type EmailVerified struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Name string `json:"name"` +} + +type PasswordResetRequested struct { + Email string `json:"email"` + Name string `json:"name"` + ResetToken string `json:"reset_token"` +} +``` + +- [x] **Step 2: Build and commit** + +```bash +go build ./... +git add internal/authentication/domain/event/auth_events.go +git commit -m "feat: add JSON tags to auth event structs" +``` + +--- + +### Task 3: Type Registry + Event Registration + +**Files:** +- Create: `internal/shared/events/registry.go` +- Create: `internal/shared/events/registry_test.go` +- Modify: `internal/todo/module.go` +- Modify: `internal/authentication/module.go` + +**Interfaces:** +- Produces: `events.Register(eventType string, factory func() interface{})` for registration +- Produces: `events.CreatePayload(eventType string) interface{}` for deserialization + +- [x] **Step 1: Write failing test** + +Create `internal/shared/events/registry_test.go`: +```go +package events + +import ( + "testing" +) + +type testPayload struct { + Message string `json:"message"` +} + +func init() { + Register("test.event", func() interface{} { return &testPayload{} }) +} + +func TestRegistry_RegisterAndCreate(t *testing.T) { + p := CreatePayload("test.event") + if p == nil { + t.Fatal("expected non-nil payload") + } + if _, ok := p.(*testPayload); !ok { + t.Fatal("expected *testPayload type") + } +} + +func TestRegistry_UnknownType(t *testing.T) { + p := CreatePayload("unknown.event") + if p != nil { + t.Fatal("expected nil for unknown type") + } +} +``` + +- [x] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/shared/events/ -run "TestRegistry" -v +``` +Expected: FAIL (CreatePayload not defined) + +- [x] **Step 3: Write minimal implementation** + +Create `internal/shared/events/registry.go`: +```go +package events + +var registry = map[string]func() interface{}{} + +func Register(eventType string, factory func() interface{}) { + registry[eventType] = factory +} + +func CreatePayload(eventType string) interface{} { + factory, ok := registry[eventType] + if !ok { + return nil + } + return factory() +} +``` + +- [x] **Step 4: Run test to verify it passes** + +```bash +go test ./internal/shared/events/ -run "TestRegistry" -v +``` +Expected: PASS + +- [x] **Step 5: Register todo events in todo/module.go** + +In `internal/todo/module.go`, add to imports: +```go +todoEvent "github.com/IDTS-LAB/go-codebase/internal/todo/domain/event" +``` + +Add to `fx.Invoke` block: +```go +func() { + events.Register(todoEvent.TodoCreatedEvent, func() interface{} { return &todoEvent.TodoCreated{} }) + events.Register(todoEvent.TodoUpdatedEvent, func() interface{} { return &todoEvent.TodoUpdated{} }) + events.Register(todoEvent.TodoCompletedEvent, func() interface{} { return &todoEvent.TodoCompleted{} }) + events.Register(todoEvent.TodoDeletedEvent, func() interface{} { return &todoEvent.TodoDeleted{} }) +}, +``` + +- [x] **Step 6: Register auth events in authentication/module.go** + +In `internal/authentication/module.go`, add to imports: +```go +authEvent "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/event" +``` + +Add to `fx.Invoke` block: +```go +func() { + events.Register(authEvent.UserRegisteredEvent, func() interface{} { return &authEvent.UserRegistered{} }) + events.Register(authEvent.EmailVerifiedEvent, func() interface{} { return &authEvent.EmailVerified{} }) + events.Register(authEvent.PasswordResetRequestedEvent, func() interface{} { return &authEvent.PasswordResetRequested{} }) +}, +``` + +- [x] **Step 7: Build, run all tests, and commit** + +```bash +go build ./... +go test ./... +git add internal/shared/events/registry.go internal/shared/events/registry_test.go internal/todo/module.go internal/authentication/module.go +git commit -m "feat: add event type registry for JSON deserialization" +``` + +--- + +### Task 4: NATS Server JetStream + NATSMessenger JetStream Context + +**Files:** +- Modify: `docker-compose.yml` +- Modify: `docker-compose.dev.yml` +- Modify: `internal/infrastructure/messaging/nats.go` + +**Interfaces:** +- Produces: NATS server with JetStream enabled (`-js` flag) +- Produces: `(*NATSMessenger).JetStream() nats.JetStreamContext` accessor + +- [x] **Step 1: Enable JetStream on NATS server** + +In `docker-compose.yml`, change NATS command: +```yaml +nats: + image: nats:2-alpine + ports: + - "4222:4222" + - "8222:8222" + command: ["--http_port", "8222", "-js"] + networks: + - app-network +``` + +Same in `docker-compose.dev.yml`: +```yaml +nats: + image: nats:2-alpine + ports: + - "4222:4222" + - "8222:8222" + command: ["--http_port", "8222", "-js"] + networks: + - dev-network +``` + +- [x] **Step 2: Add JetStream context to NATSMessenger** + +In `internal/infrastructure/messaging/nats.go`, update the struct and constructor: +```go +type NATSMessenger struct { + conn *nats.Conn + js nats.JetStreamContext + debugBuf *debugBuffer +} + +func NewNATSMessenger(cfg *config.Config) (*NATSMessenger, error) { + m := &NATSMessenger{} + if cfg.NATS.DebugEndpoint { + m.debugBuf = newDebugBuffer(100) + } + if cfg.NATS.URL == "" { + return m, nil + } + + conn, err := nats.Connect(cfg.NATS.URL) + if err != nil { + return nil, fmt.Errorf("connect nats: %w", err) + } + m.conn = conn + + js, err := conn.JetStream() + if err != nil { + return nil, fmt.Errorf("jetstream: %w", err) + } + m.js = js + + return m, nil +} +``` + +Add JetStream accessor: +```go +func (n *NATSMessenger) JetStream() nats.JetStreamContext { + return n.js +} +``` + +- [x] **Step 3: Provide JetStream context from messaging module** + +In `internal/infrastructure/messaging/nats.go`, add to the `Module`: +```go +fx.Provide(func(m *NATSMessenger) nats.JetStreamContext { + return m.JetStream() +}), +``` + +Add `"net/http"` to the import if not already there. The module now becomes: +```go +var Module = fx.Module("nats", + fx.Provide( + NewNATSMessenger, + fx.Annotate( + func(m *NATSMessenger) domain.Messenger { return m }, + fx.As(new(domain.Messenger)), + ), + func(m *NATSMessenger) nats.JetStreamContext { return m.JetStream() }, + ), +) +``` + +- [x] **Step 4: Build and commit** + +```bash +go build ./... +git add docker-compose.yml docker-compose.dev.yml internal/infrastructure/messaging/nats.go +git commit -m "feat: enable NATS JetStream, add JetStream context to NATSMessenger" +``` + +--- + +### Task 5: NATSEventBus with JetStream Publish/Subscribe + Ack/Nak + +**Files:** +- Create: `internal/shared/events/nats_event_bus.go` +- Create: `internal/shared/events/nats_event_bus_test.go` + +**Interfaces:** +- Consumes: `events.Register`, `events.CreatePayload` (from Task 3), `(*NATSMessenger).JetStream()` (from Task 4) +- Produces: `NATSEventBus` implementing `events.EventBus` interface with JetStream + +- [x] **Step 1: Write failing test** + +Create `internal/shared/events/nats_event_bus_test.go`: +```go +package events + +import ( + "context" + "errors" + "sync" + "testing" +) + +type mockJetStream struct { + mu sync.Mutex + published []struct{ subject string; data []byte } + subscribed []struct{ subject, durable, queue string } + deliverFunc func(subject string, ackFn func() error, nakFn func()) +} + +func (m *mockJetStream) Publish(subject string, data []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + m.published = append(m.published, struct{ subject string; data []byte }{subject, data}) + return nil +} + +func (m *mockJetStream) Subscribe(subject string, cb func(msg *jetStreamMsg)) error { + m.mu.Lock() + m.subscribed = append(m.subscribed, struct{ subject, durable, queue string }{subject, "event-bus", "event-bus"}) + m.mu.Unlock() + return nil +} + +type jetStreamMsg struct { + subject string + data []byte + acked bool + naked bool +} + +func (m *jetStreamMsg) Ack() error { + m.acked = true + return nil +} + +func (m *jetStreamMsg) Nak() error { + m.naked = true + return nil +} + +func (m *mockJetStream) deliver(subject string, data []byte) *jetStreamMsg { + msg := &jetStreamMsg{subject: subject, data: data} + if m.deliverFunc != nil { + m.deliverFunc(subject, func() error { return msg.Ack() }, func() { msg.Nak() }) + } + return msg +} + +type testPayload struct { + Msg string `json:"msg"` +} + +func TestNATSEventBus_Publish(t *testing.T) { + mock := &mockJetStream{} + bus := NewNATSEventBus(mock) + + ctx := context.Background() + err := bus.Publish(ctx, Event{ + Type: "nats.test.event", + Payload: &testPayload{Msg: "hello"}, + }) + if err != nil { + t.Fatalf("publish: %v", err) + } + + mock.mu.Lock() + published := len(mock.published) + mock.mu.Unlock() + + if published != 1 { + t.Fatalf("expected 1 publish, got %d", published) + } +} + +func TestNATSEventBus_SubscribeAcksOnSuccess(t *testing.T) { + Register("nats.test.event2", func() interface{} { return &testPayload{} }) + + mock := &mockJetStream{} + bus := NewNATSEventBus(mock) + + bus.Subscribe("nats.test.event2", func(_ context.Context, event Event) error { + return nil + }) + + msg := mock.deliver("nats.test.event2", []byte(`{"msg":"world"}`)) + + if !msg.acked { + t.Fatal("expected Ack on successful handler") + } + if msg.naked { + t.Fatal("expected no Nak on successful handler") + } +} + +func TestNATSEventBus_SubscribeNaksOnError(t *testing.T) { + Register("nats.test.event3", func() interface{} { return &testPayload{} }) + + mock := &mockJetStream{} + bus := NewNATSEventBus(mock) + + bus.Subscribe("nats.test.event3", func(_ context.Context, event Event) error { + return errors.New("handler error") + }) + + msg := mock.deliver("nats.test.event3", []byte(`{"msg":"fail"}`)) + + if !msg.naked { + t.Fatal("expected Nak on handler error") + } + if msg.acked { + t.Fatal("expected no Ack on handler error") + } +} +``` + +- [x] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/shared/events/ -run "TestNATSEventBus" -v +``` +Expected: FAIL (types not defined) + +- [x] **Step 3: Write minimal implementation** + +Create `internal/shared/events/nats_event_bus.go`: +```go +package events + +import ( + "context" + "encoding/json" + "sync" + + "github.com/nats-io/nats.go" +) + +type jetStreamMsg interface { + Ack() error + Nak() error + Data() []byte +} + +type jetStreamer interface { + Publish(subject string, data []byte) error + Subscribe(subject string, cb func(msg jetStreamMsg)) error +} + +type jsMsgAdapter struct { + msg *nats.Msg +} + +func (a *jsMsgAdapter) Ack() error { return a.msg.Ack() } +func (a *jsMsgAdapter) Nak() error { return a.msg.Nak() } +func (a *jsMsgAdapter) Data() []byte { return a.msg.Data } + +type jsContextAdapter struct { + js nats.JetStreamContext +} + +func (a *jsContextAdapter) Publish(subject string, data []byte) error { + _, err := a.js.Publish(subject, data) + return err +} + +func (a *jsContextAdapter) Subscribe(subject string, cb func(msg jetStreamMsg)) error { + _, err := a.js.Subscribe(subject, func(msg *nats.Msg) { + cb(&jsMsgAdapter{msg: msg}) + }, nats.Durable("event-bus"), nats.DeliverGroup("event-bus"), + nats.MaxDeliver(-1), nats.AckWait(30*1e9), nats.ManualAck()) + return err +} + +type NATSEventBus struct { + js jetStreamer + mu sync.RWMutex + handlers map[string][]Handler +} + +func NewNATSEventBus(js jetStreamer) *NATSEventBus { + return &NATSEventBus{ + js: js, + handlers: make(map[string][]Handler), + } +} + +func (b *NATSEventBus) Publish(ctx context.Context, event Event) error { + data, err := json.Marshal(event.Payload) + if err != nil { + return err + } + subject := "events." + event.Type + return b.js.Publish(subject, data) +} + +func (b *NATSEventBus) Subscribe(eventType string, handler Handler) { + b.mu.Lock() + defer b.mu.Unlock() + + if len(b.handlers[eventType]) == 0 { + b.startSubscription(eventType) + } + b.handlers[eventType] = append(b.handlers[eventType], handler) +} + +func (b *NATSEventBus) startSubscription(eventType string) { + subject := "events." + eventType + _ = b.js.Subscribe(subject, func(msg jetStreamMsg) { + payload := CreatePayload(eventType) + if payload == nil { + msg.Ack() + return + } + if err := json.Unmarshal(msg.Data(), payload); err != nil { + msg.Ack() + return + } + event := Event{Type: eventType, Payload: payload} + + b.mu.RLock() + handlers := b.handlers[eventType] + b.mu.RUnlock() + + for _, h := range handlers { + if err := h(context.Background(), event); err != nil { + msg.Nak() + return + } + } + msg.Ack() + }) +} +``` + +- [x] **Step 4: Run test to verify it passes** + +```bash +go test ./internal/shared/events/ -run "TestNATSEventBus" -v +``` +Expected: PASS + +- [x] **Step 5: Build to check compilation** + +```bash +go build ./... +``` + +- [x] **Step 6: Commit** + +```bash +git add internal/shared/events/nats_event_bus.go internal/shared/events/nats_event_bus_test.go +git commit -m "feat: add NATSEventBus with JetStream Ack/Nak delivery" +``` + +--- + +### Task 6: Module Wiring — Config-Driven EventBus Selection + JetStream Setup + +**Files:** +- Modify: `internal/shared/events/module.go` + +**Interfaces:** +- Consumes: `cfg.Events.Driver`, `NATSEventBus`, `InMemoryEventBus`, `nats.JetStreamContext`, `events.Register` +- Produces: `events.EventBus` wired via Fx as the correct implementation + +- [x] **Step 1: Wire EventBus in events/module.go** + +Read `internal/shared/events/module.go` first, then replace with config-driven bus selection: +```go +package events + +import ( + "go.uber.org/fx" + + "github.com/nats-io/nats.go" + "github.com/IDTS-LAB/go-codebase/internal/shared/config" +) + +func ensureStream(js nats.JetStreamContext, cfg config.StreamConfig) { + _, err := js.AddStream(&nats.StreamConfig{ + Name: cfg.Name, + Subjects: cfg.Subjects, + Storage: nats.FileStorage, + Retention: nats.InterestPolicy, + }) + if err != nil && err != nats.ErrStreamNameAlreadyInUse { + // log but don't fatal + } +} + +func provideEventBus(cfg *config.Config, js nats.JetStreamContext) EventBus { + if cfg.Events.Driver == "nats" { + ensureStream(js, cfg.NATS.Stream) + return NewNATSEventBus(&jsContextAdapter{js: js}) + } + return NewInMemoryEventBus() +} + +var Module = fx.Module("events", + fx.Provide(provideEventBus), +) +``` + +- [x] **Step 2: Build, run tests, and commit** + +```bash +go build ./... +go test ./... +git add internal/shared/events/module.go +git commit -m "feat: wire config-driven EventBus with JetStream stream setup" +``` diff --git a/docs/superpowers/plans/2026-07-14-nats-grafana-dashboard.md b/docs/superpowers/plans/2026-07-14-nats-grafana-dashboard.md new file mode 100644 index 0000000..37c8612 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-nats-grafana-dashboard.md @@ -0,0 +1,685 @@ +# NATS Grafana Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a Grafana dashboard tracking NATS server metrics, per-subject message throughput, and recent message payloads via a debug endpoint. + +**Architecture:** Instrument the NATSMessenger with Prometheus counters (per-subject), add an in-memory ring buffer for recent messages exposed as `/debug/nats`, and provision a Grafana dashboard with three rows: server health, per-subject activity, and a payload viewer via Infinity datasource. + +**Tech Stack:** Go 1.25, NATS v2, Prometheus, Grafana 10.4, Infinity plugin, Chi router, Uber Fx + +## Global Constraints + +- Follow existing code patterns (promauto for metrics, Chi for routing, Uber Fx for DI) +- Debug endpoint disabled by default (`NATS_DEBUG_ENDPOINT=false`) +- Metrics use `prometheus/client_golang` (already in go.mod) +- Dashboard JSON uses Grafana schema version 39 (matching existing dashboards) +- Infinity datasource plugin: `yesoreyeram-infinity-datasource` + +--- + +### Task 1: Config — Add Debug Endpoint Toggle + +**Files:** +- Modify: `internal/shared/config/config.go:72-74` +- Modify: `configs/config.yaml:43-44` +- Modify: `.env:39` + +**Interfaces:** +- Consumes: existing `NATSConfig` struct +- Produces: `cfg.NATS.DebugEndpoint bool` available for use in route registration + +- [ ] **Step 1: Add `DebugEndpoint` field to `NATSConfig`** + +In `internal/shared/config/config.go`, change: +```go +type NATSConfig struct { + URL string `koanf:"url"` +} +``` +to: +```go +type NATSConfig struct { + URL string `koanf:"url"` + DebugEndpoint bool `koanf:"debug_endpoint"` +} +``` + +- [ ] **Step 2: Add env override for debug endpoint** + +In `internal/shared/config/config.go`, after the NATS env block (after line 391), add: +```go +if v := os.Getenv("NATS_DEBUG_ENDPOINT"); v != "" { + cfg.NATS.DebugEndpoint = v == "true" || v == "1" +} +``` + +- [ ] **Step 3: Add config YAML** + +In `configs/config.yaml`, change: +```yaml +nats: + url: nats://localhost:4222 +``` +to: +```yaml +nats: + url: nats://localhost:4222 + debug_endpoint: false +``` + +- [ ] **Step 4: Add .env entry** + +In `.env`, after `NATS_URL`, add: +``` +NATS_DEBUG_ENDPOINT=false +``` + +- [ ] **Step 5: Run check and commit** + +```bash +go build ./... +git add internal/shared/config/config.go configs/config.yaml .env +git commit -m "feat: add NATS debug endpoint config toggle" +``` + +--- + +### Task 2: Instrument NATSMessenger with Prometheus Counters + +**Files:** +- Modify: `internal/infrastructure/messaging/nats.go` + +**Interfaces:** +- Consumes: `domain.Messenger` interface (unchanged) +- Produces: Prometheus metrics `nats_published_total`, `nats_received_total`, `nats_publish_bytes_total`, `nats_received_bytes_total` available at `/metrics` + +- [ ] **Step 1: Add Prometheus imports and metric vars** + +Replace the content of `internal/infrastructure/messaging/nats.go` with: +```go +package messaging + +import ( + "context" + "fmt" + + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/config" + "github.com/nats-io/nats.go" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "go.uber.org/fx" +) + +var Module = fx.Module("nats", + fx.Provide( + NewNATSMessenger, + fx.Annotate( + func(m *NATSMessenger) domain.Messenger { return m }, + fx.As(new(domain.Messenger)), + ), + ), +) + +var ( + natsPublishedTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "nats_published_total", + Help: "Total number of NATS messages published", + }, []string{"subject"}) + + natsReceivedTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "nats_received_total", + Help: "Total number of NATS messages received", + }, []string{"subject"}) + + natsPublishBytesTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "nats_publish_bytes_total", + Help: "Total bytes published to NATS", + }, []string{"subject"}) + + natsReceivedBytesTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "nats_received_bytes_total", + Help: "Total bytes received from NATS", + }, []string{"subject"}) +) + +type NATSMessenger struct { + conn *nats.Conn +} + +func NewNATSMessenger(cfg *config.Config) (*NATSMessenger, error) { + if cfg.NATS.URL == "" { + return &NATSMessenger{}, nil + } + + conn, err := nats.Connect(cfg.NATS.URL) + if err != nil { + return nil, fmt.Errorf("connect nats: %w", err) + } + + return &NATSMessenger{conn: conn}, nil +} + +func (n *NATSMessenger) Publish(ctx context.Context, subject string, data []byte) error { + if n.conn == nil { + return nil + } + natsPublishedTotal.WithLabelValues(subject).Inc() + natsPublishBytesTotal.WithLabelValues(subject).Add(float64(len(data))) + return n.conn.Publish(subject, data) +} + +func (n *NATSMessenger) Subscribe(ctx context.Context, subject string, handler func(data []byte)) error { + if n.conn == nil { + return nil + } + _, err := n.conn.Subscribe(subject, func(msg *nats.Msg) { + natsReceivedTotal.WithLabelValues(subject).Inc() + natsReceivedBytesTotal.WithLabelValues(subject).Add(float64(len(msg.Data))) + handler(msg.Data) + }) + return err +} + +func (n *NATSMessenger) Close() error { + if n.conn != nil { + return n.conn.Drain() + } + return nil +} +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +go build ./... +``` + +- [ ] **Step 3: Commit** + +```bash +git add internal/infrastructure/messaging/nats.go +git commit -m "feat: instrument NATSMessenger with Prometheus per-subject counters" +``` + +--- + +### Task 3: Create NATS Debug Endpoint + +**Files:** +- Create: `internal/infrastructure/messaging/debug.go` +- Create: `internal/infrastructure/messaging/debug_test.go` + +**Interfaces:** +- Consumes: `NATSMessenger` connection state, `cfg.NATS.DebugEndpoint` toggle +- Produces: `GET /debug/nats` HTTP handler returning JSON array of recent messages + +- [ ] **Step 1: Write the failing test** + +Create `internal/infrastructure/messaging/debug_test.go`: +```go +package messaging + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestDebugBuffer_AppendAndRead(t *testing.T) { + buf := newDebugBuffer(3) + buf.append("foo", []byte("hello")) + buf.append("bar", []byte("world")) + entries := buf.read() + + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries[0].Subject != "bar" { + t.Fatalf("expected newest first: bar, got %s", entries[0].Subject) + } + if entries[1].Subject != "foo" { + t.Fatalf("expected second: foo, got %s", entries[1].Subject) + } +} + +func TestDebugBuffer_Capacity(t *testing.T) { + buf := newDebugBuffer(2) + buf.append("a", []byte("1")) + buf.append("b", []byte("2")) + buf.append("c", []byte("3")) + + entries := buf.read() + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries[0].Subject != "c" { + t.Fatalf("expected newest: c, got %s", entries[0].Subject) + } +} + +func TestDebugHandler_Enabled(t *testing.T) { + handler := &debugNATSHandler{buffer: newDebugBuffer(10)} + handler.buffer.append("test.subj", []byte(`{"msg":"hello"}`)) + + req := httptest.NewRequest("GET", "/debug/nats", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp []debugEntry + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp) != 1 { + t.Fatalf("expected 1 entry, got %d", len(resp)) + } + if resp[0].Subject != "test.subj" { + t.Fatalf("expected test.subj, got %s", resp[0].Subject) + } +} + +func TestDebugHandler_NotEnabled(t *testing.T) { + handler := &debugNATSHandler{buffer: nil} + + req := httptest.NewRequest("GET", "/debug/nats", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/infrastructure/messaging/ -run "TestDebug" -v +``` +Expected: FAIL (types/functions not defined) + +- [ ] **Step 3: Write minimal implementation** + +Create `internal/infrastructure/messaging/debug.go`: +```go +package messaging + +import ( + "encoding/json" + "net/http" + "sync" + "time" +) + +type debugEntry struct { + Subject string `json:"subject"` + Timestamp int64 `json:"timestamp"` + Payload string `json:"payload"` +} + +type debugBuffer struct { + mu sync.RWMutex + buf []debugEntry + cap int + next int + full bool +} + +func newDebugBuffer(capacity int) *debugBuffer { + return &debugBuffer{ + buf: make([]debugEntry, capacity), + cap: capacity, + } +} + +func (b *debugBuffer) append(subject string, data []byte) { + b.mu.Lock() + defer b.mu.Unlock() + + payload := string(data) + if len(payload) > 1024 { + payload = payload[:1024] + } + + b.buf[b.next] = debugEntry{ + Subject: subject, + Timestamp: time.Now().UnixMilli(), + Payload: payload, + } + b.next = (b.next + 1) % b.cap + if b.next == 0 { + b.full = true + } +} + +func (b *debugBuffer) read() []debugEntry { + b.mu.RLock() + defer b.mu.RUnlock() + + var start, count int + if b.full { + count = b.cap + start = b.next + } else { + count = b.next + start = 0 + } + + entries := make([]debugEntry, count) + for i := 0; i < count; i++ { + idx := (start + i) % b.cap + entries[i] = b.buf[idx] + } + + // Reverse so newest is first + for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { + entries[i], entries[j] = entries[j], entries[i] + } + + return entries +} + +type debugNATSHandler struct { + buffer *debugBuffer +} + +func (h *debugNATSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if h.buffer == nil { + http.NotFound(w, r) + return + } + entries := h.buffer.read() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +go test ./internal/infrastructure/messaging/ -run "TestDebug" -v +``` +Expected: ALL PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/infrastructure/messaging/debug.go internal/infrastructure/messaging/debug_test.go +git commit -m "feat: add NATS debug endpoint with in-memory ring buffer" +``` + +--- + +### Task 4: Wire Debug Endpoint Into Router + +**Files:** +- Modify: `internal/infrastructure/messaging/nats.go` +- Modify: `internal/shared/router/router.go` +- Modify: `cmd/api/main.go` + +**Interfaces:** +- Consumes: `cfg.NATS.DebugEndpoint` (from Task 1), `NATSMessenger` (records messages into buffer) +- Produces: `/debug/nats` route registered on the API router + +- [ ] **Step 1: Integrate debug buffer into NATSMessenger** + +In `internal/infrastructure/messaging/nats.go`, modify `NATSMessenger` to hold a debug buffer. Change the struct and constructor: + +```go +type NATSMessenger struct { + conn *nats.Conn + debugBuf *debugBuffer +} + +func NewNATSMessenger(cfg *config.Config) (*NATSMessenger, error) { + m := &NATSMessenger{} + if cfg.NATS.DebugEndpoint { + m.debugBuf = newDebugBuffer(100) + } + if cfg.NATS.URL == "" { + return m, nil + } + + conn, err := nats.Connect(cfg.NATS.URL) + if err != nil { + return nil, fmt.Errorf("connect nats: %w", err) + } + m.conn = conn + return m, nil +} +``` + +Add to `Publish()`, at the start: +```go +if n.debugBuf != nil { + n.debugBuf.append(subject, data) +} +``` + +Add a method to expose the handler: +```go +func (n *NATSMessenger) DebugHandler() http.Handler { + return &debugNATSHandler{buffer: n.debugBuf} +} +``` + +Add `"net/http"` to imports. + +- [ ] **Step 2: Add DebugNATSHandler to router Handlers struct** + +In `internal/shared/router/router.go`, add to the `Handlers` struct: +```go +type Handlers struct { + Auth *chi.Mux + Todo *chi.Mux + Authz *chi.Mux + User *chi.Mux + Tenant *chi.Mux + MetricsHandler http.Handler + DebugNATSHandler http.Handler +} +``` + +Add registration after the metrics handler block (after line 45): +```go +if h.DebugNATSHandler != nil { + r.Get("/debug/nats", h.DebugNATSHandler.ServeHTTP) +} +``` + +- [ ] **Step 3: Populate and pass the handler in main.go** + +In `cmd/api/main.go`, add to the `var` block: +```go +var ( + ... + natsMessenger *messaging.NATSMessenger + debugNATSHandler http.Handler +) +``` + +Add to `fx.Populate`: +```go +fx.Populate(&natsMessenger), +``` + +After `fx.Populate` block, add: +```go +if natsMessenger != nil { + debugNATSHandler = natsMessenger.DebugHandler() +} +``` + +Add `debugNATSHandler` to `router.Handlers`: +```go +root := router.NewRouter(router.Handlers{ + ... + DebugNATSHandler: debugNATSHandler, +}, mw, log, cfg, db) +``` + +- [ ] **Step 4: Build and verify** + +```bash +go build ./... +``` + +- [ ] **Step 5: Commit** + +```bash +git add internal/infrastructure/messaging/nats.go internal/shared/router/router.go cmd/api/main.go +git commit -m "feat: wire NATS debug endpoint into API router" +``` + +--- + +### Task 5: Create Grafana Dashboard JSON + +**Files:** +- Create: `deployments/grafana/dashboards/nats.json` + +- [ ] **Step 1: Create the dashboard JSON** + +Create `deployments/grafana/dashboards/nats.json`: +```json +{ + "title": "NATS", + "uid": "nats", + "schemaVersion": 39, + "version": 1, + "timezone": "browser", + "panels": [ + { + "title": "Active Connections", + "type": "stat", + "gridPos": {"h": 4, "w": 4, "x": 0, "y": 0}, + "targets": [{"expr": "nats_connections", "legendFormat": ""}] + }, + { + "title": "Subscriptions", + "type": "stat", + "gridPos": {"h": 4, "w": 4, "x": 4, "y": 0}, + "targets": [{"expr": "nats_subscriptions", "legendFormat": ""}] + }, + { + "title": "Uptime", + "type": "stat", + "gridPos": {"h": 4, "w": 4, "x": 8, "y": 0}, + "targets": [{"expr": "nats_uptime_seconds", "legendFormat": ""}], + "fieldConfig": {"defaults": {"unit": "s"}} + }, + { + "title": "Messages In/Out", + "type": "graph", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 4}, + "targets": [ + {"expr": "rate(nats_messages_sent_total[5m])", "legendFormat": "sent"}, + {"expr": "rate(nats_messages_received_total[5m])", "legendFormat": "received"} + ], + "lines": true, + "fill": 1 + }, + { + "title": "Bytes In/Out", + "type": "graph", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 4}, + "targets": [ + {"expr": "rate(nats_out_bytes_total[5m])", "legendFormat": "out"}, + {"expr": "rate(nats_in_bytes_total[5m])", "legendFormat": "in"} + ], + "lines": true, + "fill": 1 + }, + { + "title": "Publish Rate by Subject", + "type": "bargauge", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 12}, + "targets": [{"expr": "rate(nats_published_total[5m])", "legendFormat": "{{subject}}"}] + }, + { + "title": "Receive Rate by Subject", + "type": "bargauge", + "gridPos": {"h": 8, "w": 8, "x": 8, "y": 12}, + "targets": [{"expr": "rate(nats_received_total[5m])", "legendFormat": "{{subject}}"}] + }, + { + "title": "Data Volume by Subject", + "type": "graph", + "gridPos": {"h": 8, "w": 8, "x": 16, "y": 12}, + "targets": [{"expr": "rate(nats_publish_bytes_total[5m])", "legendFormat": "{{subject}}"}], + "lines": true, + "fill": 1 + }, + { + "title": "Recent Messages", + "type": "table", + "gridPos": {"h": 8, "w": 24, "x": 0, "y": 20}, + "targets": [{ + "refId": "A", + "type": "json", + "url": "http://api:8080/debug/nats", + "fields": ["timestamp", "subject", "payload"] + }], + "datasource": "Infinity" + } + ] +} +``` + +- [ ] **Step 2: Update dashboard provisioning config** + +In `deployments/grafana/dashboards/dashboard.yml`, no change needed — the existing `path: /etc/grafana/provisioning/dashboards` directory already picks up all `.json` files in the directory. + +- [ ] **Step 3: Commit** + +```bash +git add deployments/grafana/dashboards/nats.json +git commit -m "feat: add NATS Grafana dashboard" +``` + +--- + +### Task 6: Add Infinity Datasource + Docker Compose Updates + +**Files:** +- Modify: `deployments/grafana/datasources/datasources.yml` +- Modify: `docker-compose.yml` + +- [ ] **Step 1: Add Infinity datasource** + +In `deployments/grafana/datasources/datasources.yml`, add after the Jaeger block: +```yaml + - name: Infinity + type: yesoreyeram-infinity-datasource + access: proxy + url: http://api:8080 + isDefault: false + editable: false +``` + +- [ ] **Step 2: Add GF_INSTALL_PLUGINS to Grafana service** + +In `docker-compose.yml`, under `grafana.environment`, add: +```yaml + - GF_INSTALL_PLUGINS=yesoreyeram-infinity-datasource +``` + +- [ ] **Step 3: Add NATS_DEBUG_ENDPOINT to API service** + +In `docker-compose.yml`, under `api.environment`, add: +```yaml + - NATS_DEBUG_ENDPOINT=true +``` + +- [ ] **Step 4: Commit** + +```bash +git add deployments/grafana/datasources/datasources.yml docker-compose.yml +git commit -m "feat: add Infinity datasource and enable NATS debug endpoint" +``` diff --git a/docs/superpowers/specs/2026-07-09-email-service-design.md b/docs/superpowers/specs/2026-07-09-email-service-design.md new file mode 100644 index 0000000..ce819f3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-email-service-design.md @@ -0,0 +1,193 @@ +# Email Service Design + +## Overview + +Add a loosely-coupled email service with HTML templates for user verification, password reset, welcome, and invite emails. The email provider is swappable via config with zero code changes. + +## Goals + +- Email provider abstraction (switch SMTP → SendGrid → Console via config) +- HTML email templates embedded in binary +- Email verification required before login +- Password reset flow via email +- Welcome and invite emails + +## Architecture + +### Domain Interface + +```go +// internal/core/domain/email.go +type Emailer interface { + SendVerification(to, name, token string) error + SendPasswordReset(to, name, token string) error + SendWelcome(to, name string) error + SendInvite(to, name, inviterName string) error +} +``` + +### Provider Implementations + +All in `internal/infrastructure/email/`: + +| Provider | File | Use case | +|----------|------|----------| +| `console` | `console.go` | Dev — prints to stdout | +| `smtp` | `smtp.go` | Production — standard SMTP | +| `sendgrid` | `sendgrid.go` | Production — SendGrid API v3 | + +Factory function `NewEmailer(cfg *config.Config) domain.Emailer` selects provider based on `cfg.Email.Provider`. + +### Config + +```yaml +email: + provider: console + from: "no-reply@myapp.com" + from_name: "MyApp" + smtp: + host: localhost + port: 587 + username: "" + password: "" + use_tls: true + sendgrid: + api_key: "" + frontend_url: "http://localhost:3000" +``` + +Env overrides: `EMAIL_PROVIDER`, `EMAIL_FROM`, `EMAIL_FROM_NAME`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_USE_TLS`, `SENDGRID_API_KEY`, `FRONTEND_URL`. + +### Templates + +Embedded via `go:embed` in `internal/infrastructure/email/templates/`. HTML with inline CSS (email-safe). + +| Template | Variables | +|----------|-----------| +| `verification.html` | Name, VerifyURL | +| `password_reset.html` | Name, ResetURL | +| `welcome.html` | Name | +| `invite.html` | Name, InviterName, InviteURL | + +### User Entity Changes + +Migration adds to `users` table: + +| Column | Type | Default | +|--------|------|---------| +| `email_verified` | BOOLEAN | false | +| `email_verify_token` | VARCHAR(255) | NULL | +| `email_verify_expires` | TIMESTAMPTZ | NULL | +| `password_reset_token` | VARCHAR(255) | NULL | +| `password_reset_expires` | TIMESTAMPTZ | NULL | + +### New Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/auth/verify-email?token=xxx` | Verify email address | +| `POST` | `/auth/forgot-password` | Request password reset | +| `POST` | `/auth/reset-password` | Reset password with token | +| `POST` | `/auth/resend-verification` | Resend verification email | + +### Flow Changes + +**Register:** +1. Create user with `email_verified=false` +2. Generate verification token (crypto random, 32 bytes hex) +3. Store token + expiry (24h) in user record +4. Send verification email +5. Return success message (no tokens issued) + +**Login:** +1. Check `email_verified` — if false, reject with "email not verified, please check your inbox" +2. Proceed with existing auth flow + +**Verify Email:** +1. Look up user by `email_verify_token` +2. Check token not expired +3. Set `email_verified=true`, clear token fields +4. Send welcome email +5. Redirect to frontend success page + +**Forgot Password:** +1. Look up user by email (silently return if not found — prevent enumeration) +2. Generate reset token (crypto random, 32 bytes hex) +3. Store token + expiry (1h) in user record +4. Send password reset email +5. Return success message + +**Reset Password:** +1. Look up user by `password_reset_token` +2. Check token not expired +3. Hash new password, update user, clear reset token fields +4. Revoke all refresh tokens (force re-login) +5. Return success message + +**Resend Verification:** +1. Look up user by email +2. If already verified, return success (idempotent) +3. Generate new verification token +4. Send verification email +5. Return success message + +## File Structure + +``` +internal/ + core/domain/ + email.go # Emailer interface + infrastructure/email/ + email.go # Factory function + console.go # Console provider + smtp.go # SMTP provider + sendgrid.go # SendGrid provider + templates/ + verification.html + password_reset.html + welcome.html + invite.html + shared/config/ + config.go # Add email config fields + authentication/ + domain/entity/user.go # Add verification/reset fields + application/service/ + authentication_service.go # Modify register/login flows + interfaces/http/ + handlers.go # Add new endpoints + routes.go # Add new routes +migrations/ + 005_add_email_verification.sql +``` + +## Testing + +- Unit test each provider (console captures output, SMTP/SendGrid mocked) +- Unit test template rendering +- Integration test register → verify → login flow +- Integration test forgot password → reset flow + +## Swapping Providers + +Change one config value, no code changes: + +```yaml +# Development +email: + provider: console + +# Production with SMTP +email: + provider: smtp + smtp: + host: smtp.gmail.com + port: 587 + username: you@gmail.com + password: app-password + +# Production with SendGrid +email: + provider: sendgrid + sendgrid: + api_key: SG.xxxx +``` diff --git a/docs/superpowers/specs/2026-07-09-sqlc-repository-migration-design.md b/docs/superpowers/specs/2026-07-09-sqlc-repository-migration-design.md new file mode 100644 index 0000000..9a666b5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-sqlc-repository-migration-design.md @@ -0,0 +1,156 @@ +# sqlc Repository Migration Design + +**Goal:** Migrate all domain repository implementations from hand-written `database/sql` queries to sqlc-generated code, keeping the existing domain interfaces, Fx wiring, and clean architecture intact. + +## Context + +The codebase already has `sqlc.yaml` (scoped to the todo domain only) and a `make sqlc` target, but the generated code was never wired in. All 9 repository implementations (42 methods) use raw `database/sql` with inline SQL strings. This migration replaces the inline SQL with sqlc-generated query methods while preserving the existing repository interface contracts. + +## Architecture + +### sqlc Configuration + +Rewrite `sqlc.yaml` with one `sql` block per domain — each produces a separate generated package. All blocks read schema from the shared `migrations/` directory. + +```yaml +version: "2" +sql: + - schema: "migrations" + queries: "internal/todo/infrastructure/persistence/queries/todo.sql" + gen: + go: + package: "sqlc" + out: "internal/todo/infrastructure/persistence/sqlc" + sql_package: "database/sql" + emit_json_tags: true + emit_empty_slices: true + + - schema: "migrations" + queries: "internal/authentication/infrastructure/persistence/queries/user.sql" + gen: + go: + package: "sqlc" + out: "internal/authentication/infrastructure/persistence/sqlc" + sql_package: "database/sql" + emit_json_tags: true + emit_empty_slices: true + + # ... one block per domain (authorization role/permission/etc, user, auditlog) +``` + +Each `sql` block can only point to one `queries` path. Domains with multiple query files (e.g. authorization has role.sql, permission.sql, role_permission.sql, user_role.sql) get one `sql` block per query file, all outputting to the same domain `sqlc/` package — OR the queries are consolidated into a single `queries.sql` per domain. We consolidate: one `queries.sql` per domain (or per sub-package for authorization), one `sql` block per domain. + +### Query Files + +One consolidated `queries.sql` per domain (in a `queries/` subdirectory of each persistence package): +- `internal/todo/infrastructure/persistence/queries/todo.sql` (replaces existing `queries.sql`) +- `internal/authentication/infrastructure/persistence/queries/queries.sql` (user + refresh_token queries) +- `internal/authorization/infrastructure/persistence/queries/queries.sql` (role + permission + role_permission + user_role queries) +- `internal/user/infrastructure/persistence/queries/queries.sql` +- `internal/shared/auditlog/queries/queries.sql` + +Each `.sql` file contains the `-- name: MethodName :one/:many/:exec` annotations that sqlc parses. The SQL mirrors the exact queries currently inlined in the hand-written repositories. + +### Generated Output + +Each domain gets a `sqlc/` subdirectory in its persistence package: +- `internal/todo/infrastructure/persistence/sqlc/` +- `internal/authentication/infrastructure/persistence/sqlc/` +- `internal/authorization/infrastructure/persistence/sqlc/` +- `internal/user/infrastructure/persistence/sqlc/` +- `internal/shared/auditlog/sqlc/` + +All `sqlc/` directories are gitignored (regenerated via `make sqlc`), matching the existing convention for generated code. + +### JSONB Handling (pqtype avoidance) + +The generated `models.go` would import `github.com/sqlc-dev/pqtype` because `audit_logs.metadata` and `error_logs.metadata` are `JSONB`. To avoid adding a new dependency, cast the JSONB columns to `[]byte` in the query SQL: + +```sql +-- name: InsertAuditLog :exec +INSERT INTO audit_logs (..., metadata) VALUES (..., $N::jsonb); + +-- name: GetAuditLog :one +SELECT id, ..., metadata::text as metadata FROM audit_logs WHERE id = $1; +``` + +This makes sqlc generate `[]byte` (or `string`) fields instead of `pqtype.JSONValue`. + +### Repository Refactoring Pattern + +Each existing repository implementation gets refactored to delegate to sqlc's generated `Queries` struct: + +```go +type userRepository struct { + db *sql.DB +} + +func NewUserRepository(db *sql.DB) repository.UserRepository { + return &userRepository{db: db} +} + +func (r *userRepository) Create(ctx context.Context, user *entity.User) error { + q := sqlc.New(r.db) + _, err := q.CreateUser(ctx, sqlc.CreateUserParams{ + ID: user.ID, Email: user.Email, ... + }) + return err +} + +func (r *userRepository) GetByEmail(ctx context.Context, email string) (*entity.User, error) { + q := sqlc.New(r.db) + row, err := q.GetUserByEmail(ctx, email) + if err != nil { return nil, fmt.Errorf("get user by email: %w", err) } + return mapRowToUser(row), nil +} +``` + +### Mapping Helpers + +A `mapRowTo(row sqlc.) *entity.` helper per repository converts generated structs to domain entities. This keeps the domain layer decoupled from sqlc's generated types. Generated structs (e.g. `sqlc.User`) are transport types; domain entities (`entity.User`) are the canonical types. + +### Transactions + +For multi-operation transactions, the `internal/shared/transaction` package provides a `*sql.Tx`. Since `*sql.Tx` satisfies sqlc's `DBTX` interface, repos build `sqlc.New(tx)` when inside a transaction context. The per-method `sqlc.New(r.db)` pattern means non-transactional calls use the pool directly, and transactional calls pass the `*sql.Tx`. + +### Fx Wiring + +Unchanged. Constructors still take `*sql.DB`, return the domain interface. Fx does not see sqlc. The `database.Module` continues to provide the single `*sql.DB`. + +### Domain Interfaces + +All existing interfaces stay exactly as they are: +- `internal/core/domain/repository.go` (generic `Repository[T]`) +- `internal/user/domain/repository/user_repository.go` +- `internal/authentication/domain/repository/authentication_repository.go` +- `internal/authorization/domain/repository/authorization_repository.go` +- `internal/todo/domain/repository/todo_repository.go` + +Only the implementations change internally. + +## Migration Order + +Least risk first: + +1. **Foundation:** Rewrite `sqlc.yaml`, add `sqlc/` dirs to `.gitignore`, document the workflow. Verify `make sqlc` runs clean. +2. **todo:** Migrate `todo_repository.go` to wrap sqlc. Replace existing `queries.sql` with `queries/todo.sql`. +3. **authentication:** Migrate `user_repository.go` + `refresh_token_repository.go` (includes email verification fields). +4. **authorization:** Migrate 4 repos (role, permission, role_permission, user_role). +5. **user:** Migrate `user_repository.go`. +6. **shared/auditlog:** Migrate `repository.go` (includes JSONB handling). +7. **Verification:** `make sqlc` + full build + test suite + vet. + +## What Does NOT Change + +- Domain interfaces, entities, services, handlers, routes, Fx modules +- Migrations (we only READ schema, we don't change it) +- Config, database connection provider +- The existing `internal/shared/transaction` package (sqlc is compatible with it) + +## Verification + +- `make sqlc` regenerates all domains cleanly +- `go build ./...` passes after each domain +- `go test ./...` passes at the end +- `go vet ./...` clean +- No new dependencies added to `go.mod` (pqtype avoided via cast) diff --git a/docs/superpowers/specs/2026-07-10-event-driven-email-design.md b/docs/superpowers/specs/2026-07-10-event-driven-email-design.md new file mode 100644 index 0000000..9d7a690 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-event-driven-email-design.md @@ -0,0 +1,270 @@ +# Event-Driven Email, Unified Response, Global Error Handling + +**Goal:** Decouple email sending from business logic via domain events, standardize all HTTP responses into a single envelope, and centralize error handling with consistent error codes. + +## Architecture + +``` + ┌──────────────────────┐ + │ EventBus (interface)│ + │ Publish / Subscribe │ + └──────┬───────────────┘ + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + InMemoryEventBus RabbitMQBus KafkaBus + (sync, now) (future) (future) + +Service → bus.Publish(event) → EventBus → EventHandler → mailer.Send*() +``` + +Response flow: +``` +Handler → service → bus.Publish() + ↓ +utils.RespondSuccess/RespondError/RespondPaginated + ↓ +{"success": true, "data": {...}, "meta": null} +``` + +Global error middleware catches panics, maps domain errors to HTTP codes. + +## Tech Stack + +- Go 1.25, Fx DI, Chi router, PostgreSQL +- Module path: `github.com/IDTS-LAB/go-codebase` +- In-memory sync bus for now (`InMemoryEventBus` with `sync.RWMutex`) +- No external deps added + +## Components + +### 1. EventBus Interface + +**File:** `internal/shared/events/events.go` (existing file, refactored) + +```go +type EventBus interface { + Publish(ctx context.Context, event Event) error + Subscribe(eventType string, handler Handler) error +} +``` + +Existing `Event` struct and `Handler` type stay unchanged. Rename current struct to `InMemoryEventBus` implementing `EventBus`. + +### 2. Domain Events (New) + +**File:** `internal/authentication/domain/event/auth_events.go` + +| Event | Type | Fields | Trigger | +|-------|------|--------|---------| +| UserRegistered | `auth.user.registered` | Email, Name, VerificationToken | Register, ResendVerification | +| EmailVerified | `auth.user.email_verified` | UserID, Email, Name | VerifyEmail | +| PasswordResetRequested | `auth.user.password_reset_requested` | Email, Name, ResetToken | ForgotPassword | + +**File:** `internal/todo/domain/event/todo_events.go` (existing — defined but never published) + +Wire existing events to actually publish from command handlers. + +### 3. Email Event Handler + +**File:** `internal/authentication/infrastructure/eventbus/email_handler.go` + +```go +type EmailHandler struct { + mailer domain.Emailer + log domain.Logger +} + +func (h *EmailHandler) Register(bus events.EventBus) { ... } +``` + +- `onUserRegistered` → `mailer.SendVerification(to, name, token)` +- `onEmailVerified` → `mailer.SendWelcome(to, name)` +- `onPasswordResetRequested` → `mailer.SendPasswordReset(to, name, token)` + +### 4. Authentication Service Changes + +**File:** `internal/authentication/application/service/authentication_service.go` + +- Remove `domain.Emailer mailer` field, add `events.EventBus bus` +- Constructor: `NewAuthenticationService(repos..., bus events.EventBus)` — removes `mailer` +- Methods changed: `Register`, `VerifyEmail`, `ForgotPassword`, `ResendVerification` all publish events instead of calling `mailer.Send*()` + +### 5. Todo Event Publishing + +**File:** `internal/todo/application/command/*.go` — 4 command handlers publish `todo.created`, `todo.updated`, `todo.completed`, `todo.deleted` + +Each handler gets `events.EventBus` injected. No changes to existing `TodoEventHandler` (still logs). + +### 6. Unified Response Envelope + +**File:** `internal/shared/utils/utils.go` (modified) + +```json +// Success (single resource) +{"success": true, "data": {...}, "meta": null} + +// Success (paginated list) +{"success": true, "data": [...], "meta": {"page": 1, "per_page": 20, "total": 100, "total_pages": 5}} + +// Error +{"success": false, "data": null, "error": {"code": "VALIDATION_ERROR", "message": "..."}} +``` + +Structs: + +```go +type APIResponse struct { + Success bool `json:"success"` + Data interface{} `json:"data"` + Meta *PaginationMeta `json:"meta"` + Error *ErrorBody `json:"error,omitempty"` +} + +type PaginationMeta struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` +} + +type ErrorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} +``` + +`Data` and `Meta` are always in the JSON output (data is null for errors, meta is null for non-paginated responses). `Error` omitted on success via `omitempty`. + +New helper functions: +- `RespondSuccess(w, data)` — 200, data, meta=null +- `RespondCreated(w, data)` — 201, data, meta=null +- `RespondPaginated(w, data, page, perPage, total)` — 200, data, meta={page, per_page, total, total_pages} +- `RespondError(w, status, code, message)` — generic error +- `RespondBadRequest(w, message)` — 400, VALIDATION_ERROR +- `RespondUnauthorized(w, message)` — 401, UNAUTHORIZED +- `RespondForbidden(w, code, message)` — 403, with custom code (FORBIDDEN, ACCOUNT_LOCKED, EMAIL_NOT_VERIFIED) +- `RespondNotFound(w, message)` — 404, NOT_FOUND +- `RespondConflict(w, message)` — 409, CONFLICT +- `RespondInternalError(w, message)` — 500, INTERNAL_ERROR + +### 7. Global Error Handling + +**Error-to-HTTP mapper** (`internal/shared/utils/utils.go`): + +```go +func MapError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, domain.ErrNotFound): + RespondNotFound(w, err.Error()) + case errors.Is(err, domain.ErrAlreadyExists): + RespondConflict(w, err.Error()) + case errors.Is(err, domain.ErrValidation): + RespondBadRequest(w, err.Error()) + case errors.Is(err, domain.ErrForbidden): + RespondForbidden(w, "FORBIDDEN", err.Error()) + case errors.Is(err, domain.ErrUnauthorized): + RespondUnauthorized(w, err.Error()) + default: + RespondInternalError(w, "internal server error") + } +} +``` + +**Standardized error codes:** + +| HTTP | Code | When | +|------|------|------| +| 400 | `VALIDATION_ERROR` | Invalid body/params/validation | +| 401 | `UNAUTHORIZED` | Missing/invalid/expired token | +| 403 | `FORBIDDEN` | Insufficient permissions | +| 403 | `ACCOUNT_LOCKED` | Account temporarily locked | +| 403 | `EMAIL_NOT_VERIFIED` | Email not verified | +| 404 | `NOT_FOUND` | Resource not found | +| 409 | `CONFLICT` | Duplicate/state conflict | +| 429 | `RATE_LIMITED` | Rate limit exceeded | +| 500 | `INTERNAL_ERROR` | Unexpected error | + +**Middleware cleanup:** + +- `ErrorHandler` (panic recovery) uses `utils.RespondInternalError` instead of hardcoded JSON +- `Authentication` middleware uses `utils.RespondUnauthorized` instead of hardcoded JSON +- `AuthenticationWithDenylist` uses `utils.RespondUnauthorized` +- `Authorization` middleware uses `utils.RespondUnauthorized` / `utils.RespondForbidden` +- Rate limit middleware uses `utils.RespondError(w, 429, "RATE_LIMITED", ...)` + +**File:** `internal/shared/middleware/middleware.go`, `internal/shared/middleware/ratelimit.go` + +### 8. Fx Wiring + +- `EventBus` provided as shared singleton from `internal/shared/events/module.go` +- `NewInMemoryEventBus()` returns `events.EventBus` interface +- `EmailHandler` registered via `fx.Invoke` in `cmd/api/main.go` +- Todo command handlers get `events.EventBus` injected +- `AuthenticationService` no longer takes `domain.Emailer` + +### 9. Pagination + +Current `internal/shared/pagination/pagination.go` already has a `Pagination` struct. List handlers that return paginated responses use `RespondPaginated` instead of `RespondSuccess`. Meta is null for non-list responses. + +Handlers that currently return paginated lists: +- `ListTodos` (todo handler) +- `SearchTodos` (todo handler) +- `ListRoles` (authorization handler) +- `ListPermissions` (authorization handler) +- `List` users (user handler) + +### 10. Documentation Updates + +- `docs/Architecture.md` — Add event-driven email + unified response + error handling sections +- `docs/FolderStructure.md` — Add new directories +- `docs/API.md` — Document response envelope, error codes, pagination format +- `docs/superpowers/specs/2026-07-09-email-service-design.md` — Reference this spec + +## Error Handling Strategy + +- Email sending: best-effort — publish errors logged, never crash handlers +- HTTP errors: `MapError` translates domain errors to consistent HTTP codes +- Panics: recovery middleware returns standardized `INTERNAL_ERROR` +- All middleware uses shared `utils.Respond*` functions (no hardcoded JSON) + +## Global Constraints + +- Do NOT modify database schemas or migrations +- Do NOT modify domain entities +- Do NOT add new external dependencies +- Module path: `github.com/IDTS-LAB/go-codebase` + +## File Structure + +| File | Status | Responsibility | +|------|--------|---------------| +| `internal/shared/events/events.go` | **Modify** | Extract EventBus interface, rename struct to InMemoryEventBus | +| `internal/shared/events/module.go` | **Create** | Fx module providing EventBus as singleton | +| `internal/shared/utils/utils.go` | **Modify** | Unified response envelope, pagination meta, error-to-HTTP mapper, new helper functions | +| `internal/shared/middleware/middleware.go` | **Modify** | Use utils.Respond* instead of hardcoded JSON | +| `internal/shared/middleware/ratelimit.go` | **Modify** | Use utils.RespondError instead of hardcoded JSON | +| `internal/authentication/domain/event/auth_events.go` | **Create** | UserRegistered, EmailVerified, PasswordResetRequested | +| `internal/authentication/infrastructure/eventbus/email_handler.go` | **Create** | Email event handler | +| `internal/authentication/application/service/authentication_service.go` | **Modify** | Remove mailer dep, add EventBus, publish events | +| `internal/authentication/application/service/authentication_service_test.go` | **Modify** | Remove mailer mock from tests | +| `internal/authentication/interfaces/http/handlers.go` | **Modify** | Use MapError + standardized codes | +| `internal/authentication/interfaces/http/handlers_test.go` | **Modify** | Update for new response structure | +| `internal/todo/application/command/create_todo_handler.go` | **Modify** | Publish todo.created | +| `internal/todo/application/command/update_todo_handler.go` | **Modify** | Publish todo.updated | +| `internal/todo/application/command/complete_todo_handler.go` | **Modify** | Publish todo.completed | +| `internal/todo/application/command/delete_todo_handler.go` | **Modify** | Publish todo.deleted | +| `internal/todo/interfaces/http/handlers.go` | **Modify** | Use MapError + standardized codes, RespondPaginated for lists | +| `internal/todo/interfaces/http/handlers_test.go` | **Modify** | Update for new response structure | +| `internal/todo/module.go` | **Modify** | Remove EventBus provide | +| `internal/authentication/module.go` | **Modify** | Remove mailer from auth service constructor params | +| `internal/authorization/interfaces/http/handlers.go` | **Modify** | Use MapError + standardized codes, RespondPaginated for lists | +| `internal/user/interfaces/http/handler.go` | **Modify** | Use MapError + standardized codes, RespondPaginated for lists | +| `cmd/api/main.go` | **Modify** | Email handler registration + shared EventBus provide | +| `docs/Architecture.md` | **Modify** | Add event-driven + response sections | +| `docs/FolderStructure.md` | **Modify** | Add new directories | +| `docs/API.md` | **Modify** | Document response envelope and error codes | + +## Future (Async) Support + +The `EventBus` interface is the extension point. A future `RabbitMQEventBus` implements the same `Publish`/`Subscribe` contract — no changes needed to services, events, or handlers. diff --git a/docs/superpowers/specs/2026-07-11-unified-response-formatter-design.md b/docs/superpowers/specs/2026-07-11-unified-response-formatter-design.md new file mode 100644 index 0000000..e1fcc6d --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-unified-response-formatter-design.md @@ -0,0 +1,182 @@ +# Unified Response Formatter Design + +**Date:** 2026-07-11 +**Topic:** Response standardization across HTTP handlers +**Status:** Approved + +## Goal + +Provide three complementary mechanisms so that HTTP handlers produce the unified `APIResponse` envelope with minimal boilerplate, while remaining testable and backward-compatible with the existing codebase. + +The unified envelope is defined as: + +```json +{ + "success": true, + "data": { ... }, + "meta": { "page": 1, "per_page": 20, "total": 100, "total_pages": 5 }, + "error": { "code": "NOT_FOUND", "message": "..." } +} +``` + +## Current State + +- `internal/shared/utils/utils.go` defines `APIResponse`, `PaginationMeta`, `ErrorBody`, and low-level `Respond*` functions. +- `internal/shared/utils/handler.go` defines `Handle`, `HandleCreated`, `HandleNoContent`, and `HandlePaginated` helpers. +- Handlers in `todo`, `authentication`, `authorization`, and `user` domains write responses manually using a mix of `RespondSuccess`, `RespondCreated`, `RespondPaginated`, `RespondError`, and `MapError`. +- There is no middleware safety net: a handler that writes raw JSON accidentally bypasses the envelope. + +## Design + +### 1. Generic Helpers + +Location: `internal/shared/utils/handler.go` + +These are one-line functions handlers call at the end of a request handler. They centralize error mapping and envelope creation. + +```go +func Handle(w http.ResponseWriter, data interface{}, err error) +func HandleCreated(w http.ResponseWriter, data interface{}, err error) +func HandleNoContent(w http.ResponseWriter, err error) +func HandlePaginated(w http.ResponseWriter, data interface{}, page, perPage, total int, err error) +``` + +Responsibilities: +- Map `err` to the correct HTTP status and error code via `MapError`. +- Write the success envelope with the appropriate HTTP status and optional pagination meta. + +Use when: +- Refactoring existing handlers quickly. +- The handler is a method on an existing struct and you do not want to change its signature. + +### 2. Automatic Middleware Formatter + +Location: `internal/shared/middleware/formatter.go` + +A middleware that buffers the handler’s response body, then rewrites it if the handler did not emit a full `APIResponse` envelope. + +Behavior: +1. Wrap `http.ResponseWriter` in a `formattingWriter` that captures `WriteHeader` calls and body bytes without sending them downstream. +2. Run the inner handler. +3. If the captured body is empty, write the buffered status and headers as-is. +4. If the captured body parses as JSON and contains a top-level `success` field, assume it is already an envelope and pass it through. +5. If the captured body is raw JSON and status < 400, wrap it as: + ```json + { "success": true, "data": , "meta": null } + ``` +6. If status >= 400 and the body is plain text, wrap it as: + ```json + { "success": false, "data": null, "error": { "code": "", "message": "" } } + ``` +7. If status >= 400 and the body is raw JSON, treat it as `{ "code": "...", "message": "..." }` and wrap it in the error envelope. If those keys are missing, use the status text and raw JSON string as the message. +8. For pagination, recognize `utils.PaginatedPayload[T]`: + ```go + type PaginatedPayload[T any] struct { + Data []T `json:"data"` + Pagination PaginationMeta `json:"pagination"` + } + ``` + When detected, unwrap it into `data` and `meta` in the final envelope. + +Use when: +- As a global safety net so no handler can accidentally bypass the envelope. +- When you want handlers to write raw DTOs directly and let the middleware handle formatting. + +Ordering: +- Place the formatter middleware **after** panic recovery, request ID, logging, and tracing, but **before** authentication/authorization error responses are written. This ensures auth errors are also normalized if they bypass helpers. + +### 3. Handler Adapter / Controller Pattern + +Location: `internal/shared/httpadapter/adapter.go` + +A small adapter layer that lets handlers be written as pure functions returning `(T, error)` instead of interacting with `http.ResponseWriter`. + +```go +func Adapt[T any](fn func(ctx context.Context, r *http.Request) (T, error)) http.HandlerFunc +func AdaptCreated[T any](fn func(ctx context.Context, r *http.Request) (T, error)) http.HandlerFunc +func AdaptNoContent(fn func(ctx context.Context, r *http.Request) error) http.HandlerFunc +func AdaptPaginated[T any](fn func(ctx context.Context, r *http.Request) (PaginatedResult[T], error)) http.HandlerFunc +``` + +Where: + +```go +type PaginatedResult[T any] struct { + Data []T + Page int + PerPage int + Total int +} +``` + +Responsibilities: +- Decode JSON body if needed (optional helper; handlers may still decode themselves). +- Call the provided function. +- Forward the result and error to `utils.Handle*`, producing the correct envelope. + +Use when: +- Writing new handlers from scratch. +- The handler is a thin translation layer between HTTP and application services. +- You want to test business logic without an `http.ResponseWriter`. + +## Integration + +``` +HTTP Request + │ + ▼ +[Recovery / RequestID / Logger / Tracer] + │ + ▼ +[Authentication / Authorization] + │ + ▼ +[Response Formatter Middleware] ◄── safety net + │ + ▼ +[Handler] + │ + ├── uses utils.Handle* directly + ├── writes raw JSON (formatter wraps it) + └── is a pure function wired via httpadapter.Adapt* +``` + +## Migration Strategy + +1. Implement and add the formatter middleware globally. No handler changes are required at this step. +2. Refactor existing handlers incrementally: + - Replace manual `RespondSuccess/RespondCreated/RespondPaginated/MapError` blocks with `utils.Handle*`. + - For handlers that are already thin, consider converting to `httpadapter.Adapt*`. +3. Update Swagger annotations to reference `utils.APIResponse` (already largely done). +4. Add unit tests for middleware and adapter before relying on them. + +## Error Mapping + +`utils.MapError` remains the single source of truth for mapping domain errors to HTTP statuses and codes. The middleware does not duplicate this logic; it only normalizes responses that did not use `MapError`. + +## Testing + +### Middleware tests +- Raw success JSON is wrapped in the success envelope. +- Raw error text with 4xx/5xx status is wrapped in the error envelope. +- Existing full envelopes are passed through unchanged. +- Empty body is passed through unchanged. +- `PaginatedPayload` is unwrapped into `data` + `meta`. +- `Content-Type: application/json` is set on wrapped responses. + +### Adapter tests +- Successful function result produces a 200 success envelope. +- Error result produces the mapped error envelope. +- Created adapter produces 201. +- No-content adapter produces 200 with `data: null`. +- Paginated adapter computes `total_pages` correctly. + +## Files Added / Modified + +- `internal/shared/utils/handler.go` — refine helpers. +- `internal/shared/utils/utils.go` — add `PaginatedPayload` and `PaginatedResult` types. +- `internal/shared/middleware/formatter.go` — new formatter middleware. +- `internal/shared/httpadapter/adapter.go` — new adapter package. +- Router setup — register formatter middleware. +- Existing handler files — opportunistically migrate to helpers/adapter. +- Tests — add middleware and adapter unit tests. diff --git a/docs/superpowers/specs/2026-07-12-cqrs-standardization-design.md b/docs/superpowers/specs/2026-07-12-cqrs-standardization-design.md new file mode 100644 index 0000000..435c469 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-cqrs-standardization-design.md @@ -0,0 +1,169 @@ +# CQRS Standardization Across All Domain Modules + +## Purpose + +Standardize every domain module (authentication, user, authorization, tenant) to use the CQRS pattern already established in the todo module, but upgraded with a central CommandBus and QueryBus for dispatch. + +## Architecture + +``` +HTTP Handler → bus.Dispatch(cmd) / bus.Ask(query) + ↓ + InMemoryCommandBus / InMemoryQueryBus + ↓ + (type-keyed handler lookup) + ↓ + CommandHandler / QueryHandler + ↓ + Domain Service / Repository + ↓ + EventBus.Publish (commands only) +``` + +### Bus Layer (`internal/shared/cqrs/`) + +Two interfaces + in-memory implementations: + +**CommandBus** +```go +type CommandHandler interface { + Handle(ctx context.Context, cmd any) (any, error) +} + +type CommandBus interface { + Dispatch(ctx context.Context, cmd any) (any, error) + Register(cmd any, handler CommandHandler) +} +``` + +**QueryBus** +```go +type QueryHandler interface { + Handle(ctx context.Context, query any) (any, error) +} + +type QueryBus interface { + Ask(ctx context.Context, query any) (any, error) + Register(query any, handler QueryHandler) +} +``` + +**Routing:** `reflect.TypeOf(cmd/query).String()` as map key, computed at registration, looked up on dispatch. No per-field reflection at runtime. + +**Cross-cutting:** Bus wraps dispatch with telemetry span + panic recovery + error wrapping. + +**Fx Module:** Provides `CommandBus` and `QueryBus` as singletons. Registration happens via `fx.Invoke` in each module. + +### Per-Module Structure + +Each module gains `application/command/` and `application/query/` packages with one file per operation. Each file exports: +- A **command/query struct** (the input) +- A **handler struct** implementing `CommandHandler`/`QueryHandler` +- A `Handle(ctx, struct) (response, error)` method + +The old `application/service/` file is either replaced by the handlers (auth, tenant, user) or kept as-is if it provides non-CQRS functionality. + +### Module Migrations + +#### Authentication Module + +Service methods → Commands/Queries: + +| Method | Becomes | Type | Events | +|--------|---------|------|--------| +| Register | RegisterUserCommand | Command | UserRegistered | +| Login | LoginQuery | Query | — | +| GenerateTokens | GenerateTokensCommand | Command | — | +| RefreshToken | RefreshTokenCommand | Command | — | +| Logout | LogoutCommand | Command | — | +| LogoutAll | LogoutAllCommand | Command | — | +| VerifyEmail | VerifyEmailCommand | Command | EmailVerified | +| ForgotPassword | ForgotPasswordCommand | Command | PasswordResetRequested | +| ResetPassword | ResetPasswordCommand | Command | — | +| ResendVerification | ResendVerificationCommand | Command | UserRegistered (reuses event) | + +#### User Module + +| Method | Becomes | Type | +|--------|---------|------| +| List | ListUsersQuery | Query | +| GetByID | GetUserQuery | Query | +| Update | UpdateUserCommand | Command | +| Delete | DeleteUserCommand | Command | + +#### Authorization Module + +| Method | Becomes | Type | +|--------|---------|------| +| CreateRole | CreateRoleCommand | Command | +| GetRole | GetRoleQuery | Query | +| ListRoles | ListRolesQuery | Query | +| UpdateRole | UpdateRoleCommand | Command | +| DeleteRole | DeleteRoleCommand | Command | +| CreatePermission | CreatePermissionCommand | Command | +| GetPermission | GetPermissionQuery | Query | +| ListPermissions | ListPermissionsQuery | Query | +| UpdatePermission | UpdatePermissionCommand | Command | +| DeletePermission | DeletePermissionCommand | Command | +| AssignRoleToUser | AssignRoleCommand | Command | +| RemoveRoleFromUser | UnassignRoleCommand | Command | +| GetUserRoles | GetUserRolesQuery | Query | +| AssignPermissionToRole | AssignPermissionCommand | Command | +| RemovePermissionFromRole | UnassignPermissionCommand | Command | +| GetRolePermissions | GetRolePermissionsQuery | Query | +| CheckPermission | CheckPermissionQuery | Query | + +#### Tenant Module + +| Method | Becomes | Type | +|--------|---------|------| +| Create | CreateTenantCommand | Command | +| GetByID | GetTenantQuery | Query | +| List | ListTenantsQuery | Query | +| Update | UpdateTenantCommand | Command | +| Delete | DeleteTenantCommand | Command | + +### HTTP Handler Changes + +Every handler method changes from: +```go +// before +resp, err := h.svc.SomeMethod(ctx, args) +``` + +to: +```go +// after +resp, err := h.bus.Dispatch(ctx, command.CreateSomeCommand{...}) +// or +resp, err := h.bus.Ask(ctx, query.SomeQuery{...}) +``` + +Each handler gets `CommandBus` and `QueryBus` injected via constructor (Fx provides them). + +### Existing Todo Module Alignment + +The todo module's existing `application/service/todo_app_service.go` (facade) is replaced by direct bus dispatch in handlers. The existing command/query handlers remain, but are registered with the bus in the module's `fx.Invoke` instead of being called through the facade. + +### Event Publishing + +Events stay where they are — published in command handlers (todo already does this, auth will adopt it). No changes to `EventBus` or event types. User, authorization, tenant commands don't publish events initially (can be added per command later). + +### Testing Strategy + +- Bus unit tests: dispatch to registered handler returns expected response, unregistered command returns error +- Each command/query handler: unit test with mocked repository (same as current service tests, just moved) +- HTTP handlers: integration test with real bus + mocked repositories +- No changes needed to existing repository mocks + +### Files Changed + +| Area | Files | +|------|-------| +| **New: cqrs package** | `internal/shared/cqrs/bus.go`, `internal/shared/cqrs/module.go` | +| **Auth module** | Replace `application/service/` with 10 command/query files, update handlers, update module.go | +| **User module** | Replace `application/service/` with 4 command/query files, update handlers, update module.go | +| **Authorization module** | Replace `application/service/` with 17 command/query files, update handlers, update module.go | +| **Tenant module** | Replace `application/service/` with 5 command/query files, update handlers, update module.go | +| **Todo module** | Remove `application/service/todo_app_service.go`, update handlers to use bus, update module.go | +| **cmd/api/main.go** | Wire cqrs module, update handler constructors | diff --git a/docs/superpowers/specs/2026-07-12-multitenancy-design.md b/docs/superpowers/specs/2026-07-12-multitenancy-design.md new file mode 100644 index 0000000..51d4aa4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-multitenancy-design.md @@ -0,0 +1,261 @@ +# Multi-Tenancy Design + +**Date:** 2026-07-12 +**Topic:** Multi-tenancy support +**Status:** Approved + +## Goal + +Add multi-tenant support with row-level isolation via `tenant_id` column, toggleable through config. When disabled, all existing code works unchanged. + +## Architecture + +**Strategy:** Row-level tenant isolation (shared DB, `tenant_id` column on domain tables). + +**Toggle:** Config flag `multitenancy.enabled`. When `false`, tenant_id is always `""` and all queries behave as they do today. + +## Tenant Resolution Pipeline + +``` +Request + → Extract JWT tenant_id claim (primary source for authenticated users) + → If JWT tenant_id is empty AND user is super-admin: + → Check X-Tenant-ID header for override + → If still empty: + → Extract from subdomain (tenant.app.com → "tenant") + → Store resolved TenantID in request context + → If multitenancy disabled OR no tenant resolved: TenantID = "" +``` + +### Resolution Sources + +1. **JWT claim** (`tenant_id`) — authoritative for authenticated users +2. **HTTP Header** (`X-Tenant-ID`) — only respected when JWT `tenant_id` is empty (super-admin override) +3. **Subdomain** — `{tenant}.{domain}` parsed from `Host` header, fallback when JWT/header absent + +### Conflict Resolution + +| JWT tenant_id | X-Tenant-ID | Subdomain | Result | +|---|---|---|---| +| `"a"` | any | any | `"a"` (JWT wins) | +| `""` | `"b"` | `"c"` | `"b"` (header wins) | +| `""` | `""` | `"c"` | `"c"` (subdomain fallback) | +| `""` | `""` | `""` | `""` (no tenant) | + +## Configuration + +```yaml +multitenancy: + enabled: false + tenant_header: "X-Tenant-ID" + tenant_jwt_claim: "tenant_id" + domain: "app.com" +``` + +Env var equivalents: `MULTITENANCY_ENABLED`, `MULTITENANCY_TENANT_HEADER`, etc. + +## Components + +### 1. Tenant Context (`internal/shared/middleware/tenant.go`) +- New context key `TenantIDKey contextKey = "tenant_id"` +- `GetTenantID(ctx) string` / helper + +### 2. Tenant Middleware (`internal/shared/middleware/tenant.go`) +- `TenantResolver(cfg *config.Config)` middleware +- Runs AFTER auth middleware +- Pipeline: JWT claim → header → subdomain → context + +### 3. Config Changes +- Add `multitenancy` section to `configs/config.yaml` +- Add `TenantConfig` struct to `internal/shared/config/config.go` + +### 4. JWT Changes +- `TokenClaims` gets `TenantID string` +- Token generation includes `tenant_id` claim + +### 5. Tenants Table + +A `tenants` table stores tenant metadata. This table is NOT tenant-scoped (it defines tenants). + +```sql +CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, -- used in subdomain resolution + domain VARCHAR(255), -- custom domain (optional) + settings JSONB NOT NULL DEFAULT '{}', -- flexible feature flags/limits + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +Settings JSONB shape (example): +```json +{ + "max_users": 100, + "max_storage_mb": 1024, + "features": ["audit_log", "email_notifications"] +} +``` + +### 6. Tenant Domain + +A `internal/tenant/` module with its own domain/application/infrastructure layers: +- **Entity:** `Tenant` (id, name, slug, domain, settings, is_active, timestamps) +- **Repository:** `TenantRepository` (CRUD + FindBySlug) +- **Service:** `TenantService` (CRUD, validation) +- **Handlers:** Admin-only CRUD endpoints under `/api/v1/admin/tenants` + +**Tenant handlers are excluded from tenant filtering** (they manage tenants themselves). + +### 7. Database Migrations + +**Migration `007_create_tenants.sql`** — creates the tenants table. + +**Migration `008_normalize_users.sql`** — splits the monolithic `users` table into purpose-specific tables: + +```sql +-- Core identity (auth module) +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL DEFAULT '', + is_active BOOLEAN NOT NULL DEFAULT true, + email_verified_at TIMESTAMPTZ, + tenant_id VARCHAR(36) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); + +-- Authentication secrets (auth module) +CREATE TABLE user_credentials ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + password_hash VARCHAR(255) NOT NULL, + last_login_at TIMESTAMPTZ +); + +-- Security/lockout (auth module) +CREATE TABLE user_security ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + login_attempts INTEGER NOT NULL DEFAULT 0, + locked_until TIMESTAMPTZ, + mfa_enabled BOOLEAN NOT NULL DEFAULT false, + mfa_secret VARCHAR(255) +); + +-- Verification and reset tokens (auth module) +CREATE TABLE user_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_type VARCHAR(50) NOT NULL, -- 'email_verification', 'password_reset' + token_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_user_tokens_user_id ON user_tokens(user_id); +CREATE INDEX idx_user_tokens_hash ON user_tokens(token_hash); + +-- Profile / personal info (user module) +CREATE TABLE user_profiles ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + first_name VARCHAR(255) NOT NULL DEFAULT '', + last_name VARCHAR(255) NOT NULL DEFAULT '', + phone VARCHAR(50), + avatar_url VARCHAR(500), + timezone VARCHAR(100) DEFAULT 'UTC', + locale VARCHAR(10) DEFAULT 'en', + bio TEXT +); + +-- Addresses (user module, one-to-many) +CREATE TABLE user_addresses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + label VARCHAR(100), -- 'Home', 'Work', 'Billing' + is_default BOOLEAN NOT NULL DEFAULT false, + street VARCHAR(255), + city VARCHAR(100), + state VARCHAR(100), + postal_code VARCHAR(20), + country VARCHAR(100), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_user_addresses_user_id ON user_addresses(user_id); + +-- Active sessions (auth module) +CREATE TABLE user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + refresh_token_hash VARCHAR(255) NOT NULL, + device_info VARCHAR(500), + ip_address VARCHAR(45), + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_user_sessions_user_id ON user_sessions(user_id); + +-- Social auth links (auth module) +CREATE TABLE user_social_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider VARCHAR(50) NOT NULL, -- 'google', 'github', 'apple' + provider_id VARCHAR(255) NOT NULL, + provider_email VARCHAR(255), + avatar_url VARCHAR(500), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(provider, provider_id) +); +CREATE INDEX idx_user_social_links_user_id ON user_social_links(user_id); + +-- User preferences (user module) +CREATE TABLE user_preferences ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + preferences JSONB NOT NULL DEFAULT '{}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +**Data migration:** The `008_normalize_users.sql` migration will use INSERT INTO ... SELECT to copy data from the old `users` table into the new normalized tables, then drop the old columns (or keep `deleted_at` on `users`). + +**Migration `009_add_tenant_id.sql`** — adds `tenant_id VARCHAR(36) NOT NULL DEFAULT ''` to all domain tables with composite indexes `(tenant_id, ...)`. + +### 9. Repository Changes +- All queries filter by `tenant_id` when present +- Helper `tenantFromCtx(ctx)` extracts tenant from context +- Creates include `TenantID` from context +- Unauthenticated endpoints skip tenant filtering + +### 10. Wire +- TenantResolver added to router middleware chain after auth + +## Toggle Behavior + +| State | tenant_id value | Query behavior | +|---|---|---| +| `enabled: false` | `""` | Everything works as today | +| `enabled: true`, no tenant resolved | `""` | Non-tenant rows (admin/legacy) | +| `enabled: true`, tenant resolved | UUID string | All queries scoped to tenant | + +## Key Files Changed + +- `internal/shared/config/config.go` — add TenantConfig +- `configs/config.yaml` — add multitenancy section +- `internal/shared/middleware/tenant.go` — new file +- `internal/infrastructure/auth/jwt.go` — add TenantID to claims +- `internal/core/domain/token.go` — add TenantID to TokenClaims +- `internal/tenant/` — new module (entity, repository, service, handlers, module.go) +- `migrations/007_create_tenants.sql` — tenants table +- `migrations/008_normalize_users.sql` — user tables split (users, credentials, security, tokens, profiles, addresses, sessions, social_links, preferences) +- `migrations/009_add_tenant_id.sql` — tenant_id columns to domain tables +- `sqlc.yaml` — add tenant_id field mapping, tenant queries +- All existing repository/sqlc files — tenant filtering (create params + WHERE clauses) +- `internal/shared/router/router.go` — wire TenantResolver middleware + admin routes +- `cmd/api/main.go` — wire tenant module +- Tests — pass tenant context where needed diff --git a/docs/superpowers/specs/2026-07-13-casbin-cursor-pagination-sqlc-migration.md b/docs/superpowers/specs/2026-07-13-casbin-cursor-pagination-sqlc-migration.md new file mode 100644 index 0000000..74dc4cb --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-casbin-cursor-pagination-sqlc-migration.md @@ -0,0 +1,362 @@ +# Casbin Standard Table, Cursor Pagination, and sqlc Migration + +**Date:** 2026-07-13 +**Status:** Spec + +## Overview + +Refactor the codebase with three major changes: +1. Casbin authorization — migrate from custom PolicyLoader to standard `casbin_rule` table + DB adapter +2. Pagination — replace offset/limit with bidirectional cursor-based pagination across all domains +3. sqlc adoption — migrate simple CRUD in `user` and `todo` repositories to sqlc, keep raw SQL for dynamic filters + +--- + +## 1. Casbin Standard Table + +### Current State + +- Custom `PolicyLoader` queries 4 tables (`user_roles` → `role_permissions` → `permissions` → `roles`) and loads flat `(user_id, resource, action)` policies into in-memory `CachedEnforcer` +- No `casbin_rule` table exists +- Policy reloads: full reload (all users) or per-user reload on RBAC changes + +### Target State + +#### New migration: `010_add_casbin_rule_table.sql` +```sql +CREATE TABLE casbin_rule ( + id SERIAL PRIMARY KEY, + ptype VARCHAR(100) NOT NULL, + v0 VARCHAR(255), + v1 VARCHAR(255), + v2 VARCHAR(255), + v3 VARCHAR(255), + v4 VARCHAR(255), + v5 VARCHAR(255) +); + +CREATE INDEX idx_casbin_rule_ptype ON casbin_rule(ptype); +CREATE INDEX idx_casbin_rule_v0 ON casbin_rule(v0); +``` + +#### Enforcer changes +- Implement custom adapter that implements `persist.Adapter` using `database/sql` + - `LoadPolicy(model)` — loads all rules from `casbin_rule` into the enforcer model + - `SavePolicy(model)` — persisted via direct writes from sync flow (atomic: DELETE + INSERT batch) + - `AddPolicy(sec, ptype, rule)` — inserts single row via `casbin_rule` + - `RemovePolicy(sec, ptype, rule)` — deletes from `casbin_rule` +- Keep `CachedEnforcer` for performance +- Enforcer initializes with `model.conf` + custom adapter +- `ReloadPolicies()` → calls `adapter.LoadPolicy()` (clears cache + reloads from `casbin_rule`) +- `ReloadUserPolicies(userID)` → deleted + re-added via adapter + +#### Sync flow on RBAC mutation +When any RBAC assignment changes (assign/unassign role or permission): + +1. Query the flattened user permissions: `SELECT ur.user_id::text, p.resource, p.action FROM user_roles ur JOIN role_permissions rp ...` +2. Delete existing `casbin_rule` entries for affected user(s) where `ptype = 'p'` +3. Insert new `(ptype='p', v0=user_id, v1=resource, v2=action)` tuples +4. Call `enforcer.LoadPolicy()` to sync in-memory cache + +This happens in the command handlers after the DB mutation commits. + +#### Model.conf (unchanged) +```ini +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act +``` + +The `g(r.sub, p.sub)` matcher still works — Casbin's DB adapter supports `g` grouping rules if we ever need role-based policies in the future. For now, policies remain flat `(user_id, resource, action)` tuples with `ptype = 'p'`. + +#### Files affected +| File | Change | +|------|--------| +| `internal/authorization/infrastructure/casbin/adapter.go` | Rewrite as custom `persist.Adapter` implementation using `database/sql` + `casbin_rule` table | +| `internal/authorization/infrastructure/casbin/enforcer.go` | Initialize with custom adapter; update sync methods | +| `internal/authorization/application/command/assign_role.go` | After DB commit, sync user policies to `casbin_rule` | +| `internal/authorization/application/command/unassign_role.go` | Same | +| `internal/authorization/application/command/assign_permission.go` | Sync affected users to `casbin_rule` | +| `internal/authorization/application/command/unassign_permission.go` | Same | +| `migrations/010_add_casbin_rule_table.sql` | New migration | + +--- + +## 2. Cursor Pagination + +### Current State +- All list endpoints use offset/limit pagination with `page`/`per_page` query params +- Response returns `total` and `total_pages` +- Repository methods: `GetAll(ctx, offset, limit int) (items, int, error)` +- Inconsistent: User module uses `limit/offset` params instead of `page/per_page` + +### Target State + +#### New shared package: `internal/shared/cursor/` + +```go +package cursor + +type Cursor struct { + Timestamp time.Time `json:"t"` + ID uuid.UUID `json:"i"` +} + +func Encode(t time.Time, id uuid.UUID) string { ... } // base64 JSON → opaque +func Decode(s string) (Cursor, error) { ... } +``` + +#### Repository interface changes + +Before: +```go +GetAll(ctx, offset, limit int) ([]*T, int, error) +List(ctx, offset, limit int) ([]*T, int, error) +Search(ctx, query string, offset, limit int) ([]*T, int, error) +``` + +After: +```go +GetAll(ctx, cursor *string, limit int) (items []*T, nextCursor, prevCursor *string, hasNext, hasPrev bool, err error) +List(ctx, cursor *string, limit int) (items []*T, nextCursor, prevCursor *string, hasNext, hasPrev bool, err error) +Search(ctx, query string, cursor *string, limit int) (items []*T, nextCursor, prevCursor *string, hasNext, hasPrev bool, err error) +``` + +#### SQL pattern for cursor pagination (descending) + +```sql +-- First page (cursor = nil): +SELECT id, ..., created_at +FROM table +WHERE deleted_at IS NULL +ORDER BY created_at DESC, id DESC +LIMIT $1 + +-- Subsequent pages (cursor = ): +SELECT id, ..., created_at +FROM table +WHERE deleted_at IS NULL + AND (created_at, id) < ($1, $2) +ORDER BY created_at DESC, id DESC +LIMIT $3 + +-- Previous page (reverse direction): +SELECT id, ..., created_at +FROM table +WHERE deleted_at IS NULL + AND (created_at, id) > ($1, $2) +ORDER BY created_at ASC, id ASC +LIMIT $3 +-- Then reverse the result order back to DESC +``` + +Implementation detail: +- Query `limit + 1` rows to detect `has_next` +- `next_cursor` = encode(created_at, id) of last item returned +- `prev_cursor` = encode(created_at, id) of first item returned (technically from the previous page) +- For forward pagination: `prev_cursor` is always computed if we store the first item's cursor from the current page +- For backward pagination (prev): query in ASC order with `>` condition, reverse results, `has_prev` = true only if there are items before the original cursor + +#### CursorMeta response envelope + +```go +type CursorMeta struct { + NextCursor *string `json:"next_cursor"` + PrevCursor *string `json:"prev_cursor"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` + Limit int `json:"limit"` +} +``` + +#### HTTP request params +``` +?cursor=&limit=20 +``` + +Default limit = 20, max = 100. + +#### Dynamic filter handling (tenant + search) + +For methods with dynamic WHERE clauses (`GetAll`/`List`/`Search` with optional `AND tenant_id = $N`), the cursor condition and order clause are appended after the dynamic parts. Parameter positioning is handled with `$N` incrementors — same as current approach, but with additional cursor params. + +Example pattern: +```go +args := []interface{}{} +whereClause := "WHERE deleted_at IS NULL" + +if tenantEnabled { + whereClause += " AND tenant_id = $1" + args = append(args, tenantID) +} + +// Cursor position +if cursor != nil { + c, _ := cursor.Decode(cursorStr) + whereClause += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", len(args)+1, len(args)+2) + args = append(args, c.Timestamp, c.ID) +} + +orderClause := "ORDER BY created_at DESC, id DESC" +limitClause := fmt.Sprintf("LIMIT $%d", len(args)+1) +args = append(args, limit+1) +``` + +#### HTTP handler pattern + +```go +cursorStr := r.URL.Query().Get("cursor") +limit := 20 +if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 { + limit = n + } +} + +q := query.ListTodosQuery{Cursor: nil, Limit: limit} +if cursorStr != "" { + q.Cursor = &cursorStr +} +result, err := bus.Dispatch(ctx, q) +``` + +#### DTO Response types +```go +type TodoListResponse struct { + Todos []TodoResponse `json:"todos"` +} +// The cursor meta is added by the utils response helper +``` + +#### Utils changes + +New response helper: +```go +func RespondCursorPaginated[T any](w http.ResponseWriter, data []T, nextCursor, prevCursor *string, hasNext, hasPrev bool, limit int) { + resp := APIResponse{ + Success: true, + Data: data, + Meta: CursorMeta{ + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + Limit: limit, + }, + } + writeJSON(w, http.StatusOK, resp) +} +``` + +#### Domains and files affected + +| Domain | Repository interface | Repo impl file | Query handlers | HTTP handlers | Routes | +|--------|---------------------|----------------|----------------|---------------|--------| +| **Todo** | `todo/domain/repository/todo_repository.go` | `todo/.../todo_repository.go` | `list_todos.go`, `search_todos.go` | `handlers.go` | — | +| **User** | `user/domain/repository/user_repository.go` | `user/.../user_repository.go` | `list_users.go` | `handler.go` | — | +| **Tenant** | `tenant/domain/repository/tenant.go` | `tenant/.../tenant_repository.go` | `list_tenants.go` | `handlers.go` | — | +| **Authz (role)** | `authorization/domain/repository/authorization_repository.go` | `authorization/.../role_repository.go` | `list_roles.go` | `handlers.go` | — | +| **Authz (perm)** | `authorization/domain/repository/authorization_repository.go` | `authorization/.../permission_repository.go` | `list_permissions.go` | `handlers.go` | — | + +Also: +- `todo/domain/service/todo_domain_service.go` — update `ListTodos`/`SearchTodos` signatures +- `internal/shared/utils/utils.go` — add `CursorMeta` struct and `RespondCursorPaginated` +- `internal/shared/router/web.go` — `ResponseFormatter` middleware handles cursor meta +- `internal/shared/middleware/formatter.go` — handle cursor meta in response formatting + +--- + +## 3. sqlc Migration for User and Todo Repositories + +### Current State +Both `user` and `todo` repositories have: +- sqlc queries defined in `.sql` files +- sqlc code generated +- But repositories ignore it and use raw `database/sql` for everything + +### Target + +#### `internal/user/infrastructure/persistence/user_repository.go` + +| Method | Current | Target | +|--------|---------|--------| +| `List` | raw SQL (dynamic tenant filter) | **Unchanged** (raw SQL) | +| `GetByID` | raw `QueryRowContext` | `sqlc.New(r.db).GetUserByID(ctx, id)` | +| `Update` | raw `ExecContext` | `sqlc.New(r.db).UpdateUser(ctx, params)` | +| `Delete` | raw `ExecContext` | `sqlc.New(r.db).DeleteUser(ctx, id)` | + +#### `internal/todo/infrastructure/persistence/todo_repository.go` + +| Method | Current | Target | +|--------|---------|--------| +| `GetAll` | raw SQL (dynamic tenant + cursor) | **Unchanged** (raw SQL) | +| `Search` | raw SQL (dynamic tenant + cursor) | **Unchanged** (raw SQL) | +| `Create` | raw `ExecContext` | `sqlc.New(r.db).CreateTodo(ctx, params)` | +| `GetByID` | raw `QueryRowContext` | `sqlc.New(r.db).GetTodoByID(ctx, id)` | +| `Update` | raw `ExecContext` | `sqlc.New(r.db).UpdateTodo(ctx, params)` | +| `Delete` | raw `ExecContext` | `sqlc.New(r.db).SoftDeleteTodo(ctx, id)` | + +#### Cleanup sqlc query files + +Remove these paginated queries from `.sql` files, then regenerate: + +**`todo.sql` — remove:** +- `ListTodos` +- `CountTodos` +- `SearchTodos` +- `CountSearchTodos` + +**`queries.sql` (user) — remove:** +- `ListUsers` +- `CountUsers` + +**`queries.sql` (authorization) — remove:** +- `ListRoles` +- `CountRoles` +- `ListPermissions` +- `CountPermissions` + +**`queries.sql` (tenant) — remove:** +- `ListTenants` +- `CountTenants` + +#### Regenerate sqlc +```bash +sqlc generate +``` + +--- + +## Dependency Changes + +**Add to go.mod:** +- `github.com/casbin/casbin/v2` (already in go.mod) + +**No new third-party adapter** — implement custom `persist.Adapter` with existing `database/sql`. + +--- + +## Migration Order + +This work should be done in this order to minimize conflicts: + +1. **Cursor package** (`internal/shared/cursor/`) — no dependencies +2. **sqlc regenerate** (cleanup paginated queries first) — no runtime impact +3. **User repository sqlc migration** — mechanical, isolated +4. **Todo repository sqlc migration** — mechanical, isolated +5. **Cursor pagination — repository layer** — change all 6 repository interfaces + implementations +6. **Cursor pagination — application layer** — update CQRS query handlers and DTOs +7. **Cursor pagination — HTTP layer** — update handlers + utils + formatter +8. **Casbin standard table** — migration + enforcer + sync commands +9. **Integration test / manual verification** diff --git a/docs/superpowers/specs/2026-07-13-error-hardening-design.md b/docs/superpowers/specs/2026-07-13-error-hardening-design.md new file mode 100644 index 0000000..a842af1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-error-hardening-design.md @@ -0,0 +1,253 @@ +# Error Hardening: SQL Injection Safety, Stacktrace in 500 Responses, Error Log Persistence + +**Date:** 2026-07-13 +**Status:** Spec + +## Overview + +Three targeted improvements: +1. **SQL injection audit** — confirm all raw SQL queries use parameterized placeholders +2. **LIKE character escaping** — escape `%` and `_` in todo search to prevent unintended wildcard matching +3. **500 error hardening** — show full `debug.Stack()` in non-production responses, persist all 500 errors to `error_logs` table with stacktraces + +--- + +## 1. SQL Injection Audit + +### Finding + +All raw SQL queries across all repositories use PostgreSQL parameterized placeholders (`$1`, `$2`, ...). The `fmt.Sprintf` calls are used exclusively for building SQL structure (WHERE clause composition, placeholder numbering) — never for interpolating values. **No injection vulnerabilities exist.** + +No changes required. + +### Affected files (no-op, documented for reference) + +| File | Pattern | +|------|---------| +| `internal/todo/infrastructure/persistence/todo_repository.go` | `GetAll`, `Search` — cursor + tenant filters | +| `internal/user/infrastructure/persistence/user_repository.go` | `List` — cursor + tenant filters | +| `internal/tenant/infrastructure/persistence/tenant_repository.go` | `List` — cursor filters | +| `internal/authorization/infrastructure/persistence/permission_repository.go` | `GetAll` — cursor + tenant filters | +| `internal/authorization/infrastructure/persistence/role_repository.go` | `GetAll` — cursor + tenant filters | +| `internal/authorization/infrastructure/casbin/adapter.go` | `RemovePolicy`, `RemoveFilteredPolicy` — column index placeholders | + +--- + +## 2. LIKE Character Escaping + +### Problem + +In `internal/todo/infrastructure/persistence/todo_repository.go:168`: + +```go +searchPattern := "%" + query + "%" +``` + +If a user searches for `"100%"`, the `%` acts as a SQL wildcard matching any string starting with `"100"`. Similarly `_` matches any single character. + +### Change + +Escape `%` → `\%` and `_` → `\_` before wrapping in LIKE pattern: + +```go +replacer := strings.NewReplacer(`%`, `\%`, `_`, `\_`) +searchPattern := "%" + replacer.Replace(query) + "%" +``` + +PostgreSQL's `ILIKE` with escaped patterns and standard `ESCAPE '\'` (default in PostgreSQL) handles this correctly. + +### File + +`internal/todo/infrastructure/persistence/todo_repository.go` + +--- + +## 3. 500 Error Hardening + +### Current Problems + +1. `MapError` default case → `RespondInternalError("internal server error")` — generic message in all environments +2. Non-panic 500s (from handlers) are saved to `error_logs` by `ErrorRecorder` middleware **without** error detail or stacktrace +3. `ErrorRecorder` is initialized with `nil` logger — if `persistError`'s DB insert fails, it panics on nil + +### Target + +- **Non-production** (`APP_ENV != "production"`): 500 response includes error message + full `debug.Stack()` in the response body +- **Production**: generic `"internal server error"` (unchanged) +- **All 500s**: persisted to `error_logs` table with error message + stacktrace (previously only panics had this) +- **Bug fix**: `ErrorRecorder` receives a proper logger + +### Design + +#### a. Error info context helpers + +**New file:** `internal/shared/utils/error_context.go` + +```go +type contextKey string + +const errorInfoKey contextKey = "error_info" + +type ErrorInfo struct { + Err error + Stack string +} + +func SetErrorInfo(ctx context.Context, err error, stack string) context.Context +func GetErrorInfo(ctx context.Context) (*ErrorInfo, bool) +``` + +Stores the originating error and stacktrace in the request context. Middleware reads it after the handler completes. + +#### b. Production flag + +**Modified:** `internal/shared/utils/utils.go` + +Add package-level variable: +```go +var IsProduction bool +``` + +Set once at startup in `internal/shared/router/router.go`: +```go +utils.IsProduction = (cfg.App.Env == "production") +``` + +#### c. Env-aware 500 response + +**Modified:** `internal/shared/utils/utils.go` + +New private helper `respond500` used by both `RespondInternalError` and `MapError`: + +```go +func respond500(w http.ResponseWriter, r *http.Request, message string) { + if IsProduction { + RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "internal server error") + return + } + // Non-production: include stack if available + info, ok := GetErrorInfo(r.Context()) + stack := "" + if ok { + stack = info.Stack + } + json.NewEncoder(w).Encode(APIResponse{ + Success: false, + Error: &ErrorBody{Code: "INTERNAL_ERROR", Message: message}, + Stack: stack, // new field — omitempty in production + }) +} +``` + +**New field on `APIResponse`:** +```go +type APIResponse struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Meta interface{} `json:"meta,omitempty"` + Error *ErrorBody `json:"error,omitempty"` + Stack string `json:"stack,omitempty"` +} +``` + +`Stack` omitted in production because `IsProduction` never sets it (empty string → JSON `omitempty` omits it). + +#### d. MapError enhancement + +**Modified:** `internal/shared/utils/handler.go` + +In the default case of `MapError`, capture the stacktrace and store in context: + +```go +default: + stack := string(debug.Stack()) + ctx := SetErrorInfo(r.Context(), err, stack) + respond500(w, r.WithContext(ctx), "internal server error") +``` + +And signature changes: `MapError` now takes `*http.Request` instead of just `http.ResponseWriter`. This is the only breaking signature change across all callers. + +Actually, to avoid changing all callers, add a new helper: +```go +func MapErrorFromRequest(w http.ResponseWriter, r *http.Request, err error) +``` + +And keep the old `MapError(w, err)` delegating to it with no request context (falls back to production-safe behavior). + +#### e. ErrorRecorder middleware fixes + +**Modified:** `internal/shared/middleware/middleware.go` + +1. Read error info from context: +```go +if info, ok := utils.GetErrorInfo(r.Context()); ok { + msg = info.Err.Error() + stack = info.Stack +} +``` + +2. Pass error message + stack to `persistError` for accurate `error_logs` entries. + +#### f. Nil logger bug fix + +**Modified:** `internal/shared/middleware/registry.go` + +```go +// Before: +ErrorRecorder: ErrorRecorder(nil, errorRepo), + +// After: +ErrorRecorder: ErrorRecorder(log, errorRepo), +``` + +### Response shape — non-production + +```json +{ + "success": false, + "error": { + "code": "INTERNAL_ERROR", + "message": "internal server error" + }, + "stack": "goroutine 1 [running]:\nruntime/debug.Stack(0x0)\n\t/usr/local/go/src/runtime/debug/stack.go:24 +0x65\n..." +} +``` + +### Response shape — production + +```json +{ + "success": false, + "error": { + "code": "INTERNAL_ERROR", + "message": "internal server error" + } +} +``` + +### Files affected + +| File | Change | +|------|--------| +| `internal/shared/utils/error_context.go` | NEW — context helpers for error info | +| `internal/shared/utils/utils.go` | Add `IsProduction`, `Stack` field to `APIResponse`, `Stack` field to `ErrorBody` or as separate field, modify `RespondInternalError` | +| `internal/shared/utils/handler.go` | Modify `MapError` default case (stack capture + context), add `MapErrorFromRequest` | +| `internal/shared/middleware/middleware.go` | ErrorRecorder reads context, passes stack to `persistError` | +| `internal/shared/middleware/registry.go` | Fix nil logger bug | +| `internal/shared/router/router.go` | Set `utils.IsProduction` from config | +| `internal/todo/infrastructure/persistence/todo_repository.go` | Escape LIKE chars | +| All handlers calling `MapError(w, err)` | Change to `MapErrorFromRequest(w, r, err)` (3 callers) | + +--- + +## Implementation Order + +1. LIKE escaping (1 file, 1 line) +2. Error info context helpers (new file) +3. `IsProduction` flag + `Stack` field in `APIResponse` +4. `respond500` helper + update `RespondInternalError` +5. `MapErrorFromRequest` + update callers +6. `ErrorRecorder` reads context +7. Fix nil logger bug in registry +8. Set `IsProduction` in router +9. Verify build + tests diff --git a/docs/superpowers/specs/2026-07-13-observability-monitoring-design.md b/docs/superpowers/specs/2026-07-13-observability-monitoring-design.md new file mode 100644 index 0000000..da8214b --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-observability-monitoring-design.md @@ -0,0 +1,90 @@ +# Observability & Monitoring Design + +## Overview + +Add full-stack monitoring with Prometheus metrics, Grafana dashboards, and Alertmanager notifications. The Go application exposes Prometheus metrics, infrastructure exporters collect database/cache metrics, and Grafana provides visual dashboards for admin analysis. + +## Architecture + +``` +Go App (8080) ── /metrics ──┐ + └─ monitoring module │ +PostgreSQL ── exporter ──┤ +Redis ── exporter ──┤── Prometheus ──▶ Alertmanager ──▶ Email / Discord / UI +NATS (8222) ── /metrics ───┤ │ +Jaeger (4318) ── OTLP ───────┘ └──▶ Grafana (provisioned dashboards) +``` + +## Components + +### 1. Go Application Metrics (`internal/monitoring/`) + +Standalone domain module with: + +- `domain/` — `MetricsRecorder` interface (IncrementCounter, ObserveHistogram, SetGauge) +- `infrastructure/prometheus/` — Prometheus implementation with: + - HTTP request counter (method, path, status) + - HTTP request duration histogram (50ms, 100ms, 250ms, 500ms, 1s, 2.5s, 5s) + - Active requests gauge + - Business event counters (user_registered, login_success, login_failed, email_sent, todo_created) +- `interfaces/http/` — Handler registering `/metrics` endpoint (promhttp.Handler()) +- `module.go` — Fx module providing the MetricsRecorder and registering the endpoint + +### 2. Middleware (`internal/shared/middleware/metrics.go`) + +HTTP middleware implementing the RED metrics pattern — Rate, Errors, Duration. Records request count, status code, and duration for every request using the MetricsRecorder interface. + +### 3. Infrastructure Exporters + +Added to docker-compose.yml: +- **postgres_exporter** (prometheuscommunity/postgres-exporter) — connections, transactions, cache hit ratio, deadlocks +- **redis_exporter** (oliver006/redis-exporter) — hit rate, memory, connected clients, command rate + +### 4. Prometheus Configuration (`deployments/prometheus/`) + +Scrape targets: +- `api:8080` — Go app /metrics +- `postgres_exporter:9187` +- `redis_exporter:9121` +- `nats:8222` — NATS monitoring endpoint + +Alerting rules (prometheus/alerts.yml): +- `HighErrorRate` — 5xx > 5% over 5m +- `HighLatency` — p99 latency > 1s over 5m +- `ServiceDown` — target unreachable 1m +- `PostgresPoolExhaustion` — connections > 80% of max +- `RedisDown` — exporter unreachable +- `HighMemoryUsage` — Go RSS > 500MB +- `HighCPUUsage` — Go CPU > 80% over 5m + +### 5. Alertmanager (`deployments/alertmanager/`) + +Three receivers: +- **Prometheus UI** — default route +- **Email** — SMTP via configured credentials +- **Discord** — webhook receiver, Discord webhook URL from env var + +### 6. Grafana Provisioning (`deployments/grafana/`) + +Auto-provisioned dashboards: +1. **Go App RED** — Request rate rps, error rate %, p50/p95/p99 latency, active requests, goroutines, memory, GC, business events +2. **PostgreSQL** — Connections, tps, cache hit ratio, deadlocks, query duration +3. **Redis** — Hit rate, memory, connected clients, commands/s +4. **Overview** — All services health, top errors, latency heatmap + +### 7. Docker Compose Updates + +- Add postgres_exporter, redis_exporter services +- Mount prometheus config directory (alerts.yml) +- Mount grafana provisioning directory +- Add alertmanager service + +## Config + +New section in `configs/config.yaml`: + +```yaml +monitoring: + metrics_path: /metrics + discord_webhook_url: "" +``` diff --git a/docs/superpowers/specs/2026-07-14-nats-event-bus-design.md b/docs/superpowers/specs/2026-07-14-nats-event-bus-design.md new file mode 100644 index 0000000..80ff9ca --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-nats-event-bus-design.md @@ -0,0 +1,272 @@ +# NATS Event Bus Design + +## Overview + +Make the EventBus switchable between the existing in-memory implementation and a new NATS-backed implementation, controlled by a config flag. The NATSEventBus uses the existing NATSMessenger for transport, a type registry for JSON deserialization, and is transparent to all existing handlers. + +## Architecture + +``` +events.driver: "memory" → InMemoryEventBus (existing, unchanged) +events.driver: "nats" → NATSEventBus + │ + ├── Publish → JetStream.Publish("events.{type}", msg) + │ + └── Subscribe → JetStream consumer "event-bus.{type}" + → push consumer, manual ACK + → only one worker receives each message + → deserialize via type registry + → dispatch to local handlers + → on success: Ack() + → on error: Nak() (retries indefinitely) +``` + +The `events.Module` selects the implementation at startup based on config. All command handlers and event handlers remain unchanged. + +## Delivery Guarantees + +When multiple API worker instances are running: + +| Property | Core NATS (memory driver) | JetStream (nats driver) | +|----------|--------------------------|------------------------| +| Dispatch | In-process, synchronous | Distributed via NATS | +| Cross-instance | N/A | Queue consumer — one worker per message | +| Persistence | None | File-backed stream | +| Delivery | At-most-once | At-least-once | +| Failure | Error returned to publisher | Nak() → retry indefinitely | +| Crash mid-handler | Message lost | Message redelivered to another worker | + +## Components + +### 1. NATSMessenger JetStream Support (`internal/infrastructure/messaging/nats.go`) + +The `NATSMessenger` gains JetStream context access: + +```go +type NATSMessenger struct { + conn *nats.Conn + js nats.JetStreamContext + debugBuf *debugBuffer +} +``` + +On connect, `NewNATSMessenger` creates a JetStream context: +```go +if conn != nil { + js, err := conn.JetStream() + if err != nil { + return nil, fmt.Errorf("jetstream: %w", err) + } + m.js = js +} +``` + +New methods: +- `JetStream() nats.JetStreamContext` — exposes the JetStream context for use by `NATSEventBus` +- `JetStream() nats.JetStreamContext` — exposes the JetStream context for use by `NATSEventBus` + +### 2. Type Registry (`internal/shared/events/registry.go`) + +New file. Maps event type strings to factory functions for JSON deserialization: + +```go +var registry = map[string]func() interface{}{} + +func Register(eventType string, factory func() interface{}) +func CreatePayload(eventType string) interface{} +``` + +Event types register themselves via `fx.Invoke` blocks in each module's Fx setup: + +| Event Type | Struct | Package | +|-----------|--------|---------| +| `auth.user.registered` | `event.UserRegistered` | `authentication/domain/event` | +| `auth.user.email_verified` | `event.EmailVerified` | `authentication/domain/event` | +| `auth.user.password_reset_requested` | `event.PasswordResetRequested` | `authentication/domain/event` | +| `todo.created` | `event.TodoCreated` | `todo/domain/event` | +| `todo.updated` | `event.TodoUpdated` | `todo/domain/event` | +| `todo.completed` | `event.TodoCompleted` | `todo/domain/event` | +| `todo.deleted` | `event.TodoDeleted` | `todo/domain/event` | + +### 3. JetStream Integration + +**Stream** — auto-created at startup (or configured externally): +```yaml +nats: + stream: + name: events + subjects: ["events.>"] + storage: file + retention: interest # keeps messages until all consumers ACK +``` + +**Consumer** — push-based, one per service instance: +```yaml +nats: + consumer: + durable_name: event-bus + deliver_group: event-bus # queue group across instances + ack_policy: explicit + max_deliver: -1 # infinite retry on Nak + ack_wait: 30s +``` + +The stream is created eagerly at startup via `ensureStream()` called from `provideEventBus()`. The push consumer is created implicitly by `QueueSubscribe` with a `Durable` name on first subscription to each subject. + +### 4. NATSEventBus (`internal/shared/events/nats_event_bus.go`) + +New file. Implements `events.EventBus` using JetStream via a thin `jetStreamer` abstraction: + +```go +type jetStreamMsg interface { + Ack() error + Nak() error + Data() []byte +} + +type jetStreamer interface { + Publish(subject string, data []byte) error + Subscribe(subject string, cb func(msg jetStreamMsg)) error +} +``` + +Two adapters bridge real NATS types to these interfaces: +- `jsMsgAdapter` wraps `*nats.Msg` → implements `jetStreamMsg` +- `jsContextAdapter` wraps `nats.JetStreamContext` → implements `jetStreamer` (calls `QueueSubscribe` with queue group `"event-bus"`, `Durable("event-bus")`, `MaxDeliver(-1)`, `AckWait(30s)`, `ManualAck()`) + +**Publish**: serializes `event.Payload` to JSON, publishes via `JetStream.Publish()` on subject `events.{event.Type}` + +**Subscribe**: stores handler locally; on first subscribe to an event type, calls `js.Subscribe()` which registers the JetStream push consumer. The NATS callback: + 1. Creates payload via `CreatePayload(eventType)` from the type registry + 2. Unmarshals JSON into the payload struct + 3. Dispatches to all local handlers for that event type + 4. If all handlers succeed → `Ack()` + 5. If any handler fails → `Nak()` (redelivers, retries indefinitely) + +Because all instances share queue group `"event-bus"`, NATS delivers each message to exactly one worker. + +### 5. Config Changes + +New `EventsConfig` struct: +```go +type EventsConfig struct { + Driver string `koanf:"driver"` // "memory" or "nats" +} +``` + +Extended `NATSConfig` with JetStream config: +```go +type NATSConfig struct { + URL string `koanf:"url"` + DebugEndpoint bool `koanf:"debug_endpoint"` + Stream StreamConfig `koanf:"stream"` + Consumer ConsumerConfig `koanf:"consumer"` +} + +type StreamConfig struct { + Name string `koanf:"name"` + Subjects []string `koanf:"subjects"` + Storage string `koanf:"storage"` + Retention string `koanf:"retention"` +} + +type ConsumerConfig struct { + DurableName string `koanf:"durable_name"` + DeliverGroup string `koanf:"deliver_group"` + AckPolicy string `koanf:"ack_policy"` + MaxDeliver int `koanf:"max_deliver"` + AckWait int `koanf:"ack_wait"` +} +``` + +Add to `configs/config.yaml`: +```yaml +events: + driver: memory +``` + +Env overrides: `EVENTS_DRIVER=nats` + +### 6. Module Wiring (`internal/shared/events/module.go`) + +Modified to switch on config via a single `provideEventBus` function: + +```go +func provideEventBus(cfg *config.Config, js nats.JetStreamContext, log domain.Logger) EventBus { + var bus EventBus + if cfg.Events.Driver == "nats" { + ensureStream(js, cfg.NATS.Stream) + bus = NewNATSEventBus(&jsContextAdapter{js: js}) + } else { + bus = NewInMemoryEventBus() + } + return NewLoggingEventBus(bus, log) +} +``` + +`NewLoggingEventBus` accepts `EventBus` interface (not `*InMemoryEventBus`), so it wraps either implementation transparently. + +`ensureStream` creates the JetStream stream on startup (idempotent — no-op if already exists): + +```go +func ensureStream(js nats.JetStreamContext, cfg config.StreamConfig) { + _, err := js.AddStream(&nats.StreamConfig{ + Name: cfg.Name, + Subjects: cfg.Subjects, + Storage: nats.FileStorage, + Retention: nats.InterestPolicy, + }) + if err != nil && err != nats.ErrStreamNameAlreadyInUse { + // log but don't fatal + } +} +``` + +### 7. Auth Event JSON Tags + +`authentication/domain/event/auth_events.go` needs JSON tags added to struct fields (for proper serialization via NATS). + +## Config + +```yaml +events: + driver: memory # "memory" or "nats" +``` + +## NATS Server + +Enable JetStream in `docker-compose.yml`: + +```yaml +nats: + image: nats:2-alpine + command: ["--http_port", "8222", "-js"] +``` + +The `-js` flag enables JetStream on the NATS server (no additional config needed for dev). + +## Files Changed + +| File | Change | +|------|--------| +| `docker-compose.yml` | Add `-js` flag to NATS server command | +| `docker-compose.dev.yml` | Add `-js` flag to NATS server command | +| `internal/infrastructure/messaging/nats.go` | Add JetStream context, expose via `JetStream()` method, provide from module | +| `internal/shared/events/registry.go` | New — type registry | +| `internal/shared/events/registry_test.go` | New — type registry tests | +| `internal/shared/events/nats_event_bus.go` | New — NATS-backed EventBus using JetStream + Ack/Nak with `jetStreamer` abstraction | +| `internal/shared/events/nats_event_bus_test.go` | New — NATSEventBus unit tests with mock JetStream | +| `internal/shared/events/logging_event_bus.go` | Change `NewLoggingEventBus` to accept `EventBus` interface | +| `internal/shared/events/module.go` | Config-driven `provideEventBus` with `ensureStream` + LoggingEventBus wrapper | +| `internal/shared/config/config.go` | Add `EventsConfig`, extend `NATSConfig` with `Stream`/`Consumer` | +| `configs/config.yaml` | Add `events.driver`, `nats.stream`, `nats.consumer` | +| `internal/authentication/domain/event/auth_events.go` | Add JSON tags | +| `internal/todo/module.go` | Register todo events in type registry (via `fx.Invoke`) | +| `internal/authentication/module.go` | Register auth events in type registry (via `fx.Invoke`) | + +## Testing + +- ✅ Unit tests for NATSEventBus Publish/Subscribe with mock `jetStreamer` (3 tests: publish, ack on success, nak on error) +- ✅ Unit tests for type registry (2 tests: register+create, unknown type returns nil) +- Integration test: start NATS with JetStream, publish event, verify handler receives deserialized payload (TBD) +- Config switching test: verify EventBus resolves to correct implementation (covered by module wiring — compile-time check) diff --git a/docs/superpowers/specs/2026-07-14-nats-grafana-dashboard-design.md b/docs/superpowers/specs/2026-07-14-nats-grafana-dashboard-design.md new file mode 100644 index 0000000..fd668d8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-nats-grafana-dashboard-design.md @@ -0,0 +1,115 @@ +# NATS Grafana Dashboard Design + +## Overview + +Add a NATS monitoring dashboard to Grafana and instrument the Go application's NATSMessenger to expose per-subject message metrics. The dashboard shows NATS server health, per-subject message throughput, and recent message payloads — all via existing Prometheus infrastructure plus a lightweight HTTP debug endpoint. + +## Architecture + +``` +NATS Server (:8222) ── /metrics ──┐ + (server-level metrics) │ + ├── Prometheus ──▶ Grafana +Go API App (:8080) │ + ├─ NATSMessenger ── /metrics ───┘ + Infinity datasource + │ (per-subject metrics) └──▶ /debug/nats (JSON) + └─ /debug/nats endpoint + (in-memory ring buffer, last 100 messages) +``` + +Grafana data sources used: +- **Prometheus** — NATS server metrics (`nats_*`) and app-instrumented per-subject metrics +- **Infinity** — fetches recent message payloads from `GET /debug/nats` + +## Components + +### 1. NATSMessenger Instrumentation (`internal/infrastructure/messaging/nats.go`) + +Add four Prometheus metrics, registered via `promauto` (same pattern as `internal/monitoring/`): + +| Metric | Type | Labels | Purpose | +|--------|------|--------|---------| +| `nats_published_total` | CounterVec | `subject` | Per-subject publish count | +| `nats_received_total` | CounterVec | `subject` | Per-subject receive count | +| `nats_publish_bytes_total` | CounterVec | `subject` | Total bytes published per subject | +| `nats_received_bytes_total` | CounterVec | `subject` | Total bytes received per subject | + +Instrument `Publish()` and `Subscribe()` to increment counters on each call. + +### 2. NATS Debug Endpoint (in a new handler file) + +New file: `internal/infrastructure/messaging/debug.go` + +- In-memory ring buffer (capacity 100) of recent published messages +- Each entry: subject, timestamp, truncated payload (max 1KB, returned as string; binary payloads base64-encoded), direction (publish/subscribe) +- Exposed as `GET /debug/nats` returning JSON array sorted newest-first +- Only active when NATS is configured **and** `nats_debug_endpoint` config is true (disabled by default) + +### 3. Route Registration (`cmd/api/main.go`) + +Register `GET /debug/nats` on the API router, gated behind `NATS_DEBUG_ENDPOINT=true` (or `nats.debug_endpoint` in config). Disabled by default to avoid exposure in production. + +### 4. Grafana Dashboard (`deployments/grafana/dashboards/nats.json`) + +Provisioned dashboard with three rows: + +**Row 1 — NATS Server Health** +| Panel | Type | Query | +|-------|------|-------| +| Active Connections | Stat | `nats_connections` | +| Subscriptions | Stat | `nats_subscriptions` | +| Uptime | Stat | `nats_uptime_seconds` | +| Messages In/Out | Time series | `rate(nats_messages_sent_total[5m])`, `rate(nats_messages_received_total[5m])` | +| Bytes In/Out | Time series | `rate(nats_in_bytes_total[5m])`, `rate(nats_out_bytes_total[5m])` | + +**Row 2 — Per-Subject Activity** +| Panel | Type | Query | +|-------|------|-------| +| Publish Rate by Subject | Bar gauge | `rate(nats_published_total[5m])` | +| Receive Rate by Subject | Bar gauge | `rate(nats_received_total[5m])` | +| Data Volume by Subject | Stacked time series | `rate(nats_publish_bytes_total[5m])` | + +**Row 3 — Recent Messages** +| Panel | Type | Data Source | +|-------|------|-------------| +| Recent Message Payloads | Table | Infinity — `GET /debug/nats`, columns: time, subject, payload | + +### 5. Infinity Datasource Provisioning + +Add Infinity datasource to `deployments/grafana/datasources/datasources.yml` pointing to the Go API's `/debug/nats` endpoint. + +**Note:** The Infinity datasource is a community plugin (`yesoreyeram-infinity-datasource`). It must be installed in Grafana (add `GF_INSTALL_PLUGINS=yesoreyeram-infinity-datasource` to Grafana's environment in docker-compose). + +## Config + +Add to `configs/config.yaml`: + +```yaml +nats: + url: nats://localhost:4222 + debug_endpoint: false # enables /debug/nats HTTP endpoint +``` + +The debug endpoint is available only when both `NATS_URL` is set and `debug_endpoint` is true. + +## Testing + +- Unit tests for the ring buffer logic +- Unit tests for Prometheus counter incrementation in `Publish()` and `Subscribe()` +- Integration test: start NATS, publish/subscribe messages, verify `/debug/nats` returns them +- Manual: start docker-compose, verify dashboard panels populate + +## Files Changed + +| File | Change | +|------|--------| +| `internal/infrastructure/messaging/nats.go` | Add Prometheus counters, instrument Publish/Subscribe | +| `internal/infrastructure/messaging/debug.go` | New — ring buffer + HTTP handler | +| `internal/shared/config/config.go` | Add `DebugEndpoint bool` to `NATSConfig` | +| `configs/config.yaml` | Add `nats.debug_endpoint: false` | +| `.env` | Add `NATS_DEBUG_ENDPOINT=false` | +| `cmd/api/main.go` | Register `/debug/nats` route | +| `docker-compose.yml` | Add `NATS_DEBUG_ENDPOINT` env to API; add `GF_INSTALL_PLUGINS` to Grafana | +| `deployments/grafana/dashboards/nats.json` | New — NATS dashboard | +| `deployments/grafana/datasources/datasources.yml` | Add Infinity datasource | +| `deployments/grafana/dashboards/dashboard.yml` | Add nats.json to provisioning | diff --git a/go.mod b/go.mod index 1d5e5f8..904a137 100644 --- a/go.mod +++ b/go.mod @@ -8,21 +8,23 @@ require ( github.com/go-playground/validator/v10 v10.23.0 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 - github.com/joho/godotenv v1.5.1 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.1.1 github.com/lib/pq v1.10.9 github.com/nats-io/nats.go v1.37.0 + github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.7.0 github.com/rs/cors v1.11.0 - github.com/stretchr/testify v1.9.0 + github.com/sendgrid/sendgrid-go v3.16.1+incompatible + github.com/stretchr/testify v1.11.1 github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/swag v1.16.6 go.opentelemetry.io/otel v1.31.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 go.opentelemetry.io/otel/sdk v1.31.0 + go.opentelemetry.io/otel/trace v1.31.0 go.uber.org/fx v1.23.0 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.53.0 @@ -30,6 +32,7 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/casbin/govaluate v1.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -54,22 +57,27 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nkeys v0.4.7 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/sendgrid/rest v2.6.9+incompatible // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/swaggo/files v1.0.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 // indirect go.opentelemetry.io/otel/metric v1.31.0 // indirect - go.opentelemetry.io/otel/trace v1.31.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/dig v1.18.0 // indirect go.uber.org/multierr v1.10.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect @@ -80,6 +88,6 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect google.golang.org/grpc v1.67.1 // indirect - google.golang.org/protobuf v1.35.1 // indirect + google.golang.org/protobuf v1.36.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bfbb94c..6306e6a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -70,16 +72,14 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= @@ -94,6 +94,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -102,6 +104,8 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/nats.go v1.37.0 h1:07rauXbVnnJvv1gfIyghFEo6lUcYRY0WXc3x7x0vUxE= github.com/nats-io/nats.go v1.37.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8= github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= @@ -110,16 +114,28 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= 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_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= +github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= +github.com/sendgrid/sendgrid-go v3.16.1+incompatible h1:zWhTmB0Y8XCDzeWIm2/BIt1GjJohAA0p6hVEaDtHWWs= +github.com/sendgrid/sendgrid-go v3.16.1+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= @@ -151,6 +167,8 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -201,8 +219,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/authentication/application/command/errors.go b/internal/authentication/application/command/errors.go new file mode 100644 index 0000000..74c867b --- /dev/null +++ b/internal/authentication/application/command/errors.go @@ -0,0 +1,17 @@ +package command + +import "errors" + +var ( + ErrInvalidCredentials = errors.New("invalid email or password") + ErrEmailAlreadyExists = errors.New("email already exists") + ErrUserNotFound = errors.New("user not found") + ErrInvalidRefreshToken = errors.New("invalid or expired refresh token") + ErrAccountDisabled = errors.New("account is disabled") + ErrAccountLocked = errors.New("account is temporarily locked") + ErrEmailNotVerified = errors.New("email not verified") + ErrInvalidVerifyToken = errors.New("invalid or expired verification token") + ErrVerifyTokenExpired = errors.New("verification token expired") + ErrInvalidResetToken = errors.New("invalid or expired reset token") + ErrResetTokenExpired = errors.New("reset token expired") +) diff --git a/internal/authentication/application/command/forgot_password.go b/internal/authentication/application/command/forgot_password.go new file mode 100644 index 0000000..26eeb90 --- /dev/null +++ b/internal/authentication/application/command/forgot_password.go @@ -0,0 +1,53 @@ +package command + +import ( + "context" + "time" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/event" + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" +) + +type ForgotPasswordCommand struct { + Email string +} + +type ForgotPasswordHandler struct { + userRepo repository.UserRepository + bus events.EventBus +} + +func NewForgotPasswordHandler(userRepo repository.UserRepository, bus events.EventBus) *ForgotPasswordHandler { + return &ForgotPasswordHandler{userRepo: userRepo, bus: bus} +} + +func (h *ForgotPasswordHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(ForgotPasswordCommand) + user, err := h.userRepo.GetByEmail(ctx, c.Email) + if err != nil { + return nil, nil + } + + token, err := generateToken() + if err != nil { + return nil, err + } + expires := time.Now().Add(1 * time.Hour) + hashed := hashToken(token) + user.PasswordResetToken = &hashed + user.PasswordResetExpires = &expires + if err := h.userRepo.Update(ctx, user); err != nil { + return nil, err + } + + _ = h.bus.Publish(ctx, events.Event{ + Type: event.PasswordResetRequestedEvent, + Payload: event.PasswordResetRequested{ + Email: user.Email, + Name: user.Name, + ResetToken: token, + }, + }) + return nil, nil +} diff --git a/internal/authentication/application/command/generate_tokens.go b/internal/authentication/application/command/generate_tokens.go new file mode 100644 index 0000000..2d2f6dd --- /dev/null +++ b/internal/authentication/application/command/generate_tokens.go @@ -0,0 +1,70 @@ +package command + +import ( + "context" + "fmt" + "time" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" + "github.com/google/uuid" +) + +type TokenPair struct { + AccessToken string + RefreshToken string + ExpiresIn int +} + +type GenerateTokensCommand struct { + User *entity.User + TokenTTL time.Duration +} + +type GenerateTokensHandler struct { + refreshRepo repository.RefreshTokenRepository + tokenService domain.TokenService +} + +func NewGenerateTokensHandler(refreshRepo repository.RefreshTokenRepository, tokenService domain.TokenService) *GenerateTokensHandler { + return &GenerateTokensHandler{refreshRepo: refreshRepo, tokenService: tokenService} +} + +func (h *GenerateTokensHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(GenerateTokensCommand) + refreshTokenTTL := 7 * 24 * time.Hour + if c.TokenTTL > 0 { + refreshTokenTTL = c.TokenTTL + } + accessTokenTTL := 15 * time.Minute + + tc := &domain.TokenClaims{ + UserID: c.User.ID.String(), + Email: c.User.Email, + Role: "user", + JTI: uuid.New().String(), + TenantID: middleware.GetTenantID(ctx), + } + accessToken, err := h.tokenService.GenerateToken(tc) + if err != nil { + return nil, fmt.Errorf("generate access token: %w", err) + } + + refreshTokenStr, err := generateToken() + if err != nil { + return nil, fmt.Errorf("generate refresh token: %w", err) + } + + refreshToken := entity.NewRefreshToken(c.User.ID, refreshTokenStr, time.Now().Add(refreshTokenTTL)) + if err = h.refreshRepo.Create(ctx, refreshToken); err != nil { + return nil, err + } + + return &TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshTokenStr, + ExpiresIn: int(accessTokenTTL.Seconds()), + }, nil +} diff --git a/internal/authentication/application/command/logout.go b/internal/authentication/application/command/logout.go new file mode 100644 index 0000000..838cff1 --- /dev/null +++ b/internal/authentication/application/command/logout.go @@ -0,0 +1,31 @@ +package command + +import ( + "context" + "time" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" +) + +type LogoutCommand struct { + RefreshToken string + AccessTokenJTI string + AccessTokenTTL time.Duration + Denylist func(ctx context.Context, jti string, ttl time.Duration) error +} + +type LogoutHandler struct { + refreshRepo repository.RefreshTokenRepository +} + +func NewLogoutHandler(refreshRepo repository.RefreshTokenRepository) *LogoutHandler { + return &LogoutHandler{refreshRepo: refreshRepo} +} + +func (h *LogoutHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(LogoutCommand) + if c.Denylist != nil && c.AccessTokenJTI != "" { + _ = c.Denylist(ctx, c.AccessTokenJTI, c.AccessTokenTTL) + } + return nil, h.refreshRepo.Revoke(ctx, c.RefreshToken) +} diff --git a/internal/authentication/application/command/logout_all.go b/internal/authentication/application/command/logout_all.go new file mode 100644 index 0000000..ecbf441 --- /dev/null +++ b/internal/authentication/application/command/logout_all.go @@ -0,0 +1,25 @@ +package command + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" + "github.com/google/uuid" +) + +type LogoutAllCommand struct { + UserID uuid.UUID +} + +type LogoutAllHandler struct { + refreshRepo repository.RefreshTokenRepository +} + +func NewLogoutAllHandler(refreshRepo repository.RefreshTokenRepository) *LogoutAllHandler { + return &LogoutAllHandler{refreshRepo: refreshRepo} +} + +func (h *LogoutAllHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(LogoutAllCommand) + return nil, h.refreshRepo.RevokeAllByUserID(ctx, c.UserID) +} diff --git a/internal/authentication/application/command/refresh_token.go b/internal/authentication/application/command/refresh_token.go new file mode 100644 index 0000000..4cddeda --- /dev/null +++ b/internal/authentication/application/command/refresh_token.go @@ -0,0 +1,58 @@ +package command + +import ( + "context" + "time" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" +) + +type RefreshTokenCommand struct { + RefreshToken string + TokenTTL time.Duration +} + +type RefreshTokenHandler struct { + refreshRepo repository.RefreshTokenRepository + userRepo repository.UserRepository + generateTokens *GenerateTokensHandler +} + +func NewRefreshTokenHandler( + refreshRepo repository.RefreshTokenRepository, + userRepo repository.UserRepository, + generateTokens *GenerateTokensHandler, +) *RefreshTokenHandler { + return &RefreshTokenHandler{refreshRepo: refreshRepo, userRepo: userRepo, generateTokens: generateTokens} +} + +func (h *RefreshTokenHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(RefreshTokenCommand) + refreshToken, err := h.refreshRepo.GetByToken(ctx, c.RefreshToken) + if err != nil { + return nil, ErrInvalidRefreshToken + } + + if refreshToken.IsExpired() || refreshToken.IsRevoked() { + return nil, ErrInvalidRefreshToken + } + + user, err := h.userRepo.GetByID(ctx, refreshToken.UserID) + if err != nil { + return nil, ErrUserNotFound + } + + if !user.IsActive { + return nil, ErrAccountDisabled + } + + if !user.EmailVerified { + return nil, ErrEmailNotVerified + } + + if err := h.refreshRepo.Revoke(ctx, c.RefreshToken); err != nil { + return nil, err + } + + return h.generateTokens.Handle(ctx, GenerateTokensCommand{User: user, TokenTTL: c.TokenTTL}) +} diff --git a/internal/authentication/application/command/register_user.go b/internal/authentication/application/command/register_user.go new file mode 100644 index 0000000..7856bda --- /dev/null +++ b/internal/authentication/application/command/register_user.go @@ -0,0 +1,85 @@ +package command + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/event" + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" + "golang.org/x/crypto/bcrypt" +) + +type RegisterUserCommand struct { + Email string + Password string + Name string +} + +type RegisterUserHandler struct { + userRepo repository.UserRepository + bus events.EventBus +} + +func NewRegisterUserHandler(userRepo repository.UserRepository, bus events.EventBus) *RegisterUserHandler { + return &RegisterUserHandler{userRepo: userRepo, bus: bus} +} + +func (h *RegisterUserHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(RegisterUserCommand) + existing, _ := h.userRepo.GetByEmail(ctx, c.Email) + if existing != nil { + return nil, ErrEmailAlreadyExists + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(c.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + + user := entity.NewUser(c.Email, string(hashedPassword), c.Name) + if err = h.userRepo.Create(ctx, user); err != nil { + return nil, err + } + + token, err := generateToken() + if err != nil { + return nil, fmt.Errorf("generate verification token: %w", err) + } + expires := time.Now().Add(24 * time.Hour) + hashed := hashToken(token) + user.EmailVerifyToken = &hashed + user.EmailVerifyExpires = &expires + if err = h.userRepo.Update(ctx, user); err != nil { + return nil, err + } + + _ = h.bus.Publish(ctx, events.Event{ + Type: event.UserRegisteredEvent, + Payload: event.UserRegistered{ + Email: user.Email, + Name: user.Name, + VerificationToken: token, + }, + }) + + return user, nil +} + +func generateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/authentication/application/command/resend_verification.go b/internal/authentication/application/command/resend_verification.go new file mode 100644 index 0000000..2ac422c --- /dev/null +++ b/internal/authentication/application/command/resend_verification.go @@ -0,0 +1,56 @@ +package command + +import ( + "context" + "time" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/event" + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" +) + +type ResendVerificationCommand struct { + Email string +} + +type ResendVerificationHandler struct { + userRepo repository.UserRepository + bus events.EventBus +} + +func NewResendVerificationHandler(userRepo repository.UserRepository, bus events.EventBus) *ResendVerificationHandler { + return &ResendVerificationHandler{userRepo: userRepo, bus: bus} +} + +func (h *ResendVerificationHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(ResendVerificationCommand) + user, err := h.userRepo.GetByEmail(ctx, c.Email) + if err != nil { + return nil, nil + } + if user.EmailVerified { + return nil, nil + } + + token, err := generateToken() + if err != nil { + return nil, err + } + expires := time.Now().Add(24 * time.Hour) + hashed := hashToken(token) + user.EmailVerifyToken = &hashed + user.EmailVerifyExpires = &expires + if err := h.userRepo.Update(ctx, user); err != nil { + return nil, err + } + + _ = h.bus.Publish(ctx, events.Event{ + Type: event.UserRegisteredEvent, + Payload: event.UserRegistered{ + Email: user.Email, + Name: user.Name, + VerificationToken: token, + }, + }) + return nil, nil +} diff --git a/internal/authentication/application/command/reset_password.go b/internal/authentication/application/command/reset_password.go new file mode 100644 index 0000000..7df2ed8 --- /dev/null +++ b/internal/authentication/application/command/reset_password.go @@ -0,0 +1,46 @@ +package command + +import ( + "context" + "time" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" + "golang.org/x/crypto/bcrypt" +) + +type ResetPasswordCommand struct { + Token string + NewPassword string +} + +type ResetPasswordHandler struct { + userRepo repository.UserRepository + refreshRepo repository.RefreshTokenRepository +} + +func NewResetPasswordHandler(userRepo repository.UserRepository, refreshRepo repository.RefreshTokenRepository) *ResetPasswordHandler { + return &ResetPasswordHandler{userRepo: userRepo, refreshRepo: refreshRepo} +} + +func (h *ResetPasswordHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(ResetPasswordCommand) + hashed := hashToken(c.Token) + user, err := h.userRepo.GetByResetToken(ctx, hashed) + if err != nil { + return nil, ErrInvalidResetToken + } + if user.PasswordResetExpires != nil && time.Now().After(*user.PasswordResetExpires) { + return nil, ErrResetTokenExpired + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(c.NewPassword), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + user.Password = string(hashedPassword) + user.PasswordResetToken = nil + user.PasswordResetExpires = nil + _ = h.refreshRepo.RevokeAllByUserID(ctx, user.ID) + return nil, h.userRepo.Update(ctx, user) +} diff --git a/internal/authentication/application/command/verify_email.go b/internal/authentication/application/command/verify_email.go new file mode 100644 index 0000000..37824c6 --- /dev/null +++ b/internal/authentication/application/command/verify_email.go @@ -0,0 +1,52 @@ +package command + +import ( + "context" + "time" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/event" + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" +) + +type VerifyEmailCommand struct { + Token string +} + +type VerifyEmailHandler struct { + userRepo repository.UserRepository + bus events.EventBus +} + +func NewVerifyEmailHandler(userRepo repository.UserRepository, bus events.EventBus) *VerifyEmailHandler { + return &VerifyEmailHandler{userRepo: userRepo, bus: bus} +} + +func (h *VerifyEmailHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(VerifyEmailCommand) + hashed := hashToken(c.Token) + user, err := h.userRepo.GetByVerifyToken(ctx, hashed) + if err != nil { + return nil, ErrInvalidVerifyToken + } + if user.EmailVerifyExpires != nil && time.Now().After(*user.EmailVerifyExpires) { + return nil, ErrVerifyTokenExpired + } + + user.EmailVerified = true + user.EmailVerifyToken = nil + user.EmailVerifyExpires = nil + if err := h.userRepo.Update(ctx, user); err != nil { + return nil, err + } + + _ = h.bus.Publish(ctx, events.Event{ + Type: event.EmailVerifiedEvent, + Payload: event.EmailVerified{ + UserID: user.ID.String(), + Email: user.Email, + Name: user.Name, + }, + }) + return map[string]string{"message": "email verified successfully"}, nil +} diff --git a/internal/authentication/application/dto/authentication_dto.go b/internal/authentication/application/dto/authentication_dto.go index 90250b3..357eeb6 100644 --- a/internal/authentication/application/dto/authentication_dto.go +++ b/internal/authentication/application/dto/authentication_dto.go @@ -15,6 +15,19 @@ type RefreshRequest struct { RefreshToken string `json:"refresh_token" validate:"required"` } +type ForgotPasswordRequest struct { + Email string `json:"email" validate:"required,email"` +} + +type ResetPasswordRequest struct { + Token string `json:"token" validate:"required"` + NewPassword string `json:"new_password" validate:"required,min=8"` +} + +type ResendVerificationRequest struct { + Email string `json:"email" validate:"required,email"` +} + type TokenResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` diff --git a/internal/authentication/application/query/errors.go b/internal/authentication/application/query/errors.go new file mode 100644 index 0000000..fb79615 --- /dev/null +++ b/internal/authentication/application/query/errors.go @@ -0,0 +1,10 @@ +package query + +import "errors" + +var ( + ErrInvalidCredentials = errors.New("invalid email or password") + ErrAccountDisabled = errors.New("account is disabled") + ErrAccountLocked = errors.New("account is temporarily locked") + ErrEmailNotVerified = errors.New("email not verified") +) diff --git a/internal/authentication/application/query/login.go b/internal/authentication/application/query/login.go new file mode 100644 index 0000000..0c46298 --- /dev/null +++ b/internal/authentication/application/query/login.go @@ -0,0 +1,62 @@ +package query + +import ( + "context" + "time" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" + "golang.org/x/crypto/bcrypt" +) + +type LoginQuery struct { + Email string + Password string +} + +type LoginHandler struct { + userRepo repository.UserRepository + maxLoginAttempts int + lockoutDuration time.Duration +} + +func NewLoginHandler(userRepo repository.UserRepository) *LoginHandler { + return &LoginHandler{ + userRepo: userRepo, + maxLoginAttempts: 5, + lockoutDuration: 15 * time.Minute, + } +} + +func (h *LoginHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(LoginQuery) + user, err := h.userRepo.GetByEmail(ctx, q.Email) + if err != nil { + return nil, ErrInvalidCredentials + } + + if !user.IsActive { + return nil, ErrAccountDisabled + } + + if user.IsLocked() { + return nil, ErrAccountLocked + } + + if !user.EmailVerified { + return nil, ErrEmailNotVerified + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(q.Password)); err != nil { + user.FailedLoginAttempts++ + if user.FailedLoginAttempts >= h.maxLoginAttempts { + user.Lock(h.lockoutDuration) + } + _ = h.userRepo.Update(ctx, user) + return nil, ErrInvalidCredentials + } + + user.Unlock() + _ = h.userRepo.Update(ctx, user) + + return user, nil +} diff --git a/internal/authentication/application/service/authentication_service.go b/internal/authentication/application/service/authentication_service.go deleted file mode 100644 index 8f9ed83..0000000 --- a/internal/authentication/application/service/authentication_service.go +++ /dev/null @@ -1,183 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "time" - - "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" - "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" - "github.com/IDTS-LAB/go-codebase/internal/core/domain" - "github.com/google/uuid" - "golang.org/x/crypto/bcrypt" -) - -var ( - ErrInvalidCredentials = errors.New("invalid email or password") - ErrEmailAlreadyExists = errors.New("email already exists") - ErrUserNotFound = errors.New("user not found") - ErrInvalidRefreshToken = errors.New("invalid or expired refresh token") - ErrAccountDisabled = errors.New("account is disabled") - ErrAccountLocked = errors.New("account is temporarily locked") -) - -type AuthenticationService struct { - userRepo repository.UserRepository - refreshRepo repository.RefreshTokenRepository - tokenService domain.TokenService - denylist func(ctx context.Context, jti string, ttl time.Duration) error - accessTokenTTL time.Duration - refreshTokenTTL time.Duration - maxLoginAttempts int - lockoutDuration time.Duration -} - -func NewAuthenticationService( - userRepo repository.UserRepository, - refreshRepo repository.RefreshTokenRepository, - tokenService domain.TokenService, -) *AuthenticationService { - return &AuthenticationService{ - userRepo: userRepo, - refreshRepo: refreshRepo, - tokenService: tokenService, - accessTokenTTL: 15 * time.Minute, - refreshTokenTTL: 7 * 24 * time.Hour, - maxLoginAttempts: 5, - lockoutDuration: 15 * time.Minute, - } -} - -func (s *AuthenticationService) SetDenylist(fn func(ctx context.Context, jti string, ttl time.Duration) error) { - s.denylist = fn -} - -func (s *AuthenticationService) SetLockoutConfig(maxAttempts int, lockoutDuration time.Duration) { - s.maxLoginAttempts = maxAttempts - s.lockoutDuration = lockoutDuration -} - -func (s *AuthenticationService) Register(ctx context.Context, email, password, name string) (*entity.User, error) { - existing, _ := s.userRepo.GetByEmail(ctx, email) - if existing != nil { - return nil, ErrEmailAlreadyExists - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return nil, fmt.Errorf("hash password: %w", err) - } - - user := entity.NewUser(email, string(hashedPassword), name) - if err := s.userRepo.Create(ctx, user); err != nil { - return nil, err - } - - return user, nil -} - -func (s *AuthenticationService) Login(ctx context.Context, email, password string) (*entity.User, error) { - user, err := s.userRepo.GetByEmail(ctx, email) - if err != nil { - return nil, ErrInvalidCredentials - } - - if !user.IsActive { - return nil, ErrAccountDisabled - } - - if user.IsLocked() { - return nil, ErrAccountLocked - } - - if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { - user.FailedLoginAttempts++ - if user.FailedLoginAttempts >= s.maxLoginAttempts { - user.Lock(s.lockoutDuration) - } - _ = s.userRepo.Update(ctx, user) - return nil, ErrInvalidCredentials - } - - user.Unlock() - _ = s.userRepo.Update(ctx, user) - - return user, nil -} - -func (s *AuthenticationService) GenerateTokens(ctx context.Context, user *entity.User) (*TokenPair, error) { - accessToken, err := s.tokenService.GenerateToken(user.ID.String(), user.Email, "user") - if err != nil { - return nil, fmt.Errorf("generate access token: %w", err) - } - - refreshTokenStr, err := generateRefreshToken() - if err != nil { - return nil, fmt.Errorf("generate refresh token: %w", err) - } - - refreshToken := entity.NewRefreshToken(user.ID, refreshTokenStr, time.Now().Add(s.refreshTokenTTL)) - if err := s.refreshRepo.Create(ctx, refreshToken); err != nil { - return nil, err - } - - return &TokenPair{ - AccessToken: accessToken, - RefreshToken: refreshTokenStr, - ExpiresIn: int(s.accessTokenTTL.Seconds()), - }, nil -} - -func (s *AuthenticationService) RefreshToken(ctx context.Context, refreshTokenStr string) (*TokenPair, error) { - refreshToken, err := s.refreshRepo.GetByToken(ctx, refreshTokenStr) - if err != nil { - return nil, ErrInvalidRefreshToken - } - - if refreshToken.IsExpired() || refreshToken.IsRevoked() { - return nil, ErrInvalidRefreshToken - } - - user, err := s.userRepo.GetByID(ctx, refreshToken.UserID) - if err != nil { - return nil, ErrUserNotFound - } - - if !user.IsActive { - return nil, ErrAccountDisabled - } - - if err := s.refreshRepo.Revoke(ctx, refreshTokenStr); err != nil { - return nil, err - } - - return s.GenerateTokens(ctx, user) -} - -func (s *AuthenticationService) Logout(ctx context.Context, refreshTokenStr string, accessTokenJTI string, accessTokenTTL time.Duration) error { - if s.denylist != nil && accessTokenJTI != "" { - _ = s.denylist(ctx, accessTokenJTI, accessTokenTTL) - } - return s.refreshRepo.Revoke(ctx, refreshTokenStr) -} - -func (s *AuthenticationService) LogoutAll(ctx context.Context, userID uuid.UUID) error { - return s.refreshRepo.RevokeAllByUserID(ctx, userID) -} - -type TokenPair struct { - AccessToken string - RefreshToken string - ExpiresIn int -} - -func generateRefreshToken() (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} diff --git a/internal/authentication/domain/entity/user.go b/internal/authentication/domain/entity/user.go index 9246753..b10ffaf 100644 --- a/internal/authentication/domain/entity/user.go +++ b/internal/authentication/domain/entity/user.go @@ -8,12 +8,17 @@ import ( type User struct { domain.Entity - Email string `json:"email"` - Password string `json:"-"` - Name string `json:"name"` - IsActive bool `json:"is_active"` - FailedLoginAttempts int `json:"failed_login_attempts"` - LockedUntil *time.Time `json:"locked_until,omitempty"` + Email string `json:"email"` + Password string `json:"-"` + Name string `json:"name"` + IsActive bool `json:"is_active"` + FailedLoginAttempts int `json:"failed_login_attempts"` + LockedUntil *time.Time `json:"locked_until,omitempty"` + EmailVerified bool `json:"email_verified"` + EmailVerifyToken *string `json:"-"` + EmailVerifyExpires *time.Time `json:"-"` + PasswordResetToken *string `json:"-"` + PasswordResetExpires *time.Time `json:"-"` } func NewUser(email, password, name string) *User { diff --git a/internal/authentication/domain/entity/user_test.go b/internal/authentication/domain/entity/user_test.go new file mode 100644 index 0000000..5ea2d52 --- /dev/null +++ b/internal/authentication/domain/entity/user_test.go @@ -0,0 +1,51 @@ +package entity + +import ( + "testing" + "time" +) + +func TestUserEmailVerificationFields(t *testing.T) { + user := NewUser("test@example.com", "hashed_password", "Test User") + + // New fields should default to zero values + if user.EmailVerified { + t.Error("new user should not be email verified") + } + if user.EmailVerifyToken != nil { + t.Error("new user should not have verify token") + } + if user.EmailVerifyExpires != nil { + t.Error("new user should not have verify expires") + } + if user.PasswordResetToken != nil { + t.Error("new user should not have reset token") + } + if user.PasswordResetExpires != nil { + t.Error("new user should not have reset expires") + } + + // Test setting verification fields + token := "verify-token-123" + expires := time.Now().Add(24 * time.Hour) + user.EmailVerifyToken = &token + user.EmailVerifyExpires = &expires + user.EmailVerified = true + + if user.EmailVerifyToken == nil || *user.EmailVerifyToken != "verify-token-123" { + t.Error("verify token not set correctly") + } + if user.EmailVerifyExpires == nil || !user.EmailVerifyExpires.Equal(expires) { + t.Error("verify expires not set correctly") + } + if !user.EmailVerified { + t.Error("email_verified not set correctly") + } + + // Test clearing verification fields + user.EmailVerifyToken = nil + user.EmailVerifyExpires = nil + if user.EmailVerifyToken != nil { + t.Error("verify token should be nil after clearing") + } +} diff --git a/internal/authentication/domain/event/auth_events.go b/internal/authentication/domain/event/auth_events.go new file mode 100644 index 0000000..7664cb9 --- /dev/null +++ b/internal/authentication/domain/event/auth_events.go @@ -0,0 +1,25 @@ +package event + +type UserRegistered struct { + Email string `json:"email"` + Name string `json:"name"` + VerificationToken string `json:"verification_token"` +} + +type EmailVerified struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Name string `json:"name"` +} + +type PasswordResetRequested struct { + Email string `json:"email"` + Name string `json:"name"` + ResetToken string `json:"reset_token"` +} + +const ( + UserRegisteredEvent = "auth.user.registered" + EmailVerifiedEvent = "auth.user.email_verified" + PasswordResetRequestedEvent = "auth.user.password_reset_requested" +) diff --git a/internal/authentication/domain/repository/authentication_repository.go b/internal/authentication/domain/repository/authentication_repository.go index 7ad27a6..2b2e8e0 100644 --- a/internal/authentication/domain/repository/authentication_repository.go +++ b/internal/authentication/domain/repository/authentication_repository.go @@ -11,6 +11,8 @@ type UserRepository interface { Create(ctx context.Context, user *entity.User) error GetByID(ctx context.Context, id uuid.UUID) (*entity.User, error) GetByEmail(ctx context.Context, email string) (*entity.User, error) + GetByVerifyToken(ctx context.Context, token string) (*entity.User, error) + GetByResetToken(ctx context.Context, token string) (*entity.User, error) Update(ctx context.Context, user *entity.User) error } diff --git a/internal/authentication/infrastructure/eventbus/email_handler.go b/internal/authentication/infrastructure/eventbus/email_handler.go new file mode 100644 index 0000000..373d147 --- /dev/null +++ b/internal/authentication/infrastructure/eventbus/email_handler.go @@ -0,0 +1,48 @@ +package eventbus + +import ( + "context" + "fmt" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/event" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" +) + +type EmailHandler struct { + mailer domain.Emailer +} + +func NewEmailHandler(mailer domain.Emailer) *EmailHandler { + return &EmailHandler{mailer: mailer} +} + +func (h *EmailHandler) Register(bus events.EventBus) { + bus.Subscribe(event.UserRegisteredEvent, h.onUserRegistered) + bus.Subscribe(event.EmailVerifiedEvent, h.onEmailVerified) + bus.Subscribe(event.PasswordResetRequestedEvent, h.onPasswordResetRequested) +} + +func (h *EmailHandler) onUserRegistered(ctx context.Context, e events.Event) error { + payload, ok := e.Payload.(event.UserRegistered) + if !ok { + return fmt.Errorf("invalid payload type for %s", e.Type) + } + return h.mailer.SendVerification(payload.Email, payload.Name, payload.VerificationToken) +} + +func (h *EmailHandler) onEmailVerified(ctx context.Context, e events.Event) error { + payload, ok := e.Payload.(event.EmailVerified) + if !ok { + return fmt.Errorf("invalid payload type for %s", e.Type) + } + return h.mailer.SendWelcome(payload.Email, payload.Name) +} + +func (h *EmailHandler) onPasswordResetRequested(ctx context.Context, e events.Event) error { + payload, ok := e.Payload.(event.PasswordResetRequested) + if !ok { + return fmt.Errorf("invalid payload type for %s", e.Type) + } + return h.mailer.SendPasswordReset(payload.Email, payload.Name, payload.ResetToken) +} diff --git a/internal/authentication/infrastructure/persistence/queries/queries.sql b/internal/authentication/infrastructure/persistence/queries/queries.sql new file mode 100644 index 0000000..0d9becf --- /dev/null +++ b/internal/authentication/infrastructure/persistence/queries/queries.sql @@ -0,0 +1,20 @@ +-- name: CreateRefreshToken :exec +INSERT INTO refresh_tokens (id, user_id, token, expires_at, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6); + +-- name: GetRefreshTokenByToken :one +SELECT id, user_id, token, expires_at, revoked_at, created_at, updated_at, deleted_at +FROM refresh_tokens WHERE token = $1 AND deleted_at IS NULL; + +-- name: GetRefreshTokensByUserID :many +SELECT id, user_id, token, expires_at, revoked_at, created_at, updated_at, deleted_at +FROM refresh_tokens WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC; + +-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens SET revoked_at = NOW(), updated_at = NOW() WHERE token = $1 AND deleted_at IS NULL AND revoked_at IS NULL; + +-- name: RevokeAllRefreshTokensByUserID :exec +UPDATE refresh_tokens SET revoked_at = NOW(), updated_at = NOW() WHERE user_id = $1 AND deleted_at IS NULL AND revoked_at IS NULL; + +-- name: DeleteExpiredRefreshTokens :exec +UPDATE refresh_tokens SET deleted_at = NOW() WHERE expires_at < NOW() AND deleted_at IS NULL; diff --git a/internal/authentication/infrastructure/persistence/refresh_token_repository.go b/internal/authentication/infrastructure/persistence/refresh_token_repository.go index b6b712f..c3ff1ca 100644 --- a/internal/authentication/infrastructure/persistence/refresh_token_repository.go +++ b/internal/authentication/infrastructure/persistence/refresh_token_repository.go @@ -7,6 +7,8 @@ import ( "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/authentication/infrastructure/persistence/sqlc" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" "github.com/google/uuid" ) @@ -19,8 +21,15 @@ func NewRefreshTokenRepository(db *sql.DB) repository.RefreshTokenRepository { } func (r *refreshTokenRepository) Create(ctx context.Context, token *entity.RefreshToken) error { - query := `INSERT INTO refresh_tokens (id, user_id, token, expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)` - _, err := r.db.ExecContext(ctx, query, token.ID, token.UserID, token.Token, token.ExpiresAt, token.CreatedAt, token.UpdatedAt) + q := sqlc.New(r.db) + err := q.CreateRefreshToken(ctx, sqlc.CreateRefreshTokenParams{ + ID: token.ID, + UserID: token.UserID, + Token: token.Token, + ExpiresAt: token.ExpiresAt, + CreatedAt: token.CreatedAt, + UpdatedAt: token.UpdatedAt, + }) if err != nil { return fmt.Errorf("insert refresh token: %w", err) } @@ -28,40 +37,33 @@ func (r *refreshTokenRepository) Create(ctx context.Context, token *entity.Refre } func (r *refreshTokenRepository) GetByToken(ctx context.Context, token string) (*entity.RefreshToken, error) { - query := `SELECT id, user_id, token, expires_at, revoked_at, created_at, updated_at, deleted_at FROM refresh_tokens WHERE token = $1 AND deleted_at IS NULL` - rt := &entity.RefreshToken{} - err := r.db.QueryRowContext(ctx, query, token).Scan(&rt.ID, &rt.UserID, &rt.Token, &rt.ExpiresAt, &rt.RevokedAt, &rt.CreatedAt, &rt.UpdatedAt, &rt.DeletedAt) + q := sqlc.New(r.db) + row, err := q.GetRefreshTokenByToken(ctx, token) if err == sql.ErrNoRows { return nil, fmt.Errorf("refresh token not found") } if err != nil { return nil, fmt.Errorf("get refresh token: %w", err) } - return rt, nil + return mapSqlcRefreshTokenToEntity(row), nil } func (r *refreshTokenRepository) GetByUserID(ctx context.Context, userID uuid.UUID) ([]*entity.RefreshToken, error) { - query := `SELECT id, user_id, token, expires_at, revoked_at, created_at, updated_at, deleted_at FROM refresh_tokens WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC` - rows, err := r.db.QueryContext(ctx, query, userID) + q := sqlc.New(r.db) + rows, err := q.GetRefreshTokensByUserID(ctx, userID) if err != nil { return nil, fmt.Errorf("get refresh tokens: %w", err) } - defer rows.Close() - - var tokens []*entity.RefreshToken - for rows.Next() { - rt := &entity.RefreshToken{} - if err := rows.Scan(&rt.ID, &rt.UserID, &rt.Token, &rt.ExpiresAt, &rt.RevokedAt, &rt.CreatedAt, &rt.UpdatedAt, &rt.DeletedAt); err != nil { - return nil, fmt.Errorf("scan refresh token: %w", err) - } - tokens = append(tokens, rt) + tokens := make([]*entity.RefreshToken, len(rows)) + for i, row := range rows { + tokens[i] = mapSqlcRefreshTokenToEntity(row) } return tokens, nil } func (r *refreshTokenRepository) Revoke(ctx context.Context, token string) error { - query := `UPDATE refresh_tokens SET revoked_at = NOW(), updated_at = NOW() WHERE token = $1 AND deleted_at IS NULL AND revoked_at IS NULL` - _, err := r.db.ExecContext(ctx, query, token) + q := sqlc.New(r.db) + err := q.RevokeRefreshToken(ctx, token) if err != nil { return fmt.Errorf("revoke refresh token: %w", err) } @@ -69,8 +71,8 @@ func (r *refreshTokenRepository) Revoke(ctx context.Context, token string) error } func (r *refreshTokenRepository) RevokeAllByUserID(ctx context.Context, userID uuid.UUID) error { - query := `UPDATE refresh_tokens SET revoked_at = NOW(), updated_at = NOW() WHERE user_id = $1 AND deleted_at IS NULL AND revoked_at IS NULL` - _, err := r.db.ExecContext(ctx, query, userID) + q := sqlc.New(r.db) + err := q.RevokeAllRefreshTokensByUserID(ctx, userID) if err != nil { return fmt.Errorf("revoke all refresh tokens: %w", err) } @@ -78,10 +80,25 @@ func (r *refreshTokenRepository) RevokeAllByUserID(ctx context.Context, userID u } func (r *refreshTokenRepository) DeleteExpired(ctx context.Context) error { - query := `UPDATE refresh_tokens SET deleted_at = NOW() WHERE expires_at < NOW() AND deleted_at IS NULL` - _, err := r.db.ExecContext(ctx, query) + q := sqlc.New(r.db) + err := q.DeleteExpiredRefreshTokens(ctx) if err != nil { return fmt.Errorf("delete expired tokens: %w", err) } return nil } + +func mapSqlcRefreshTokenToEntity(row sqlc.RefreshToken) *entity.RefreshToken { + return &entity.RefreshToken{ + Entity: domain.Entity{ + ID: row.ID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + DeletedAt: nullTimeToPtr(row.DeletedAt), + }, + UserID: row.UserID, + Token: row.Token, + ExpiresAt: row.ExpiresAt, + RevokedAt: nullTimeToPtr(row.RevokedAt), + } +} diff --git a/internal/authentication/infrastructure/persistence/user_repository.go b/internal/authentication/infrastructure/persistence/user_repository.go index cf6cec3..b485e54 100644 --- a/internal/authentication/infrastructure/persistence/user_repository.go +++ b/internal/authentication/infrastructure/persistence/user_repository.go @@ -4,9 +4,11 @@ import ( "context" "database/sql" "fmt" + "time" "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" "github.com/google/uuid" ) @@ -18,44 +20,204 @@ func NewUserRepository(db *sql.DB) repository.UserRepository { return &userRepository{db: db} } +type userRow struct { + ID uuid.UUID + Email string + Name string + IsActive bool + EmailVerifiedAt sql.NullTime + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt sql.NullTime + PasswordHash sql.NullString + LastLoginAt sql.NullTime + LoginAttempts sql.NullInt32 + LockedUntil sql.NullTime + EmailVerifyToken sql.NullString + EmailVerifyExpires sql.NullTime + PasswordResetToken sql.NullString + PasswordResetExpires sql.NullTime +} + +func scanUser(row *sql.Row) (userRow, error) { + var r userRow + err := row.Scan( + &r.ID, + &r.Email, + &r.Name, + &r.IsActive, + &r.EmailVerifiedAt, + &r.CreatedAt, + &r.UpdatedAt, + &r.DeletedAt, + &r.PasswordHash, + &r.LastLoginAt, + &r.LoginAttempts, + &r.LockedUntil, + &r.EmailVerifyToken, + &r.EmailVerifyExpires, + &r.PasswordResetToken, + &r.PasswordResetExpires, + ) + return r, err +} + func (r *userRepository) Create(ctx context.Context, user *entity.User) error { - query := `INSERT INTO users (id, email, password, name, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)` - _, err := r.db.ExecContext(ctx, query, user.ID, user.Email, user.Password, user.Name, user.IsActive, user.CreatedAt, user.UpdatedAt) + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + var emailVerifiedAt *time.Time + if user.EmailVerified { + now := time.Now().UTC() + emailVerifiedAt = &now + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO users (id, email, name, is_active, email_verified_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, user.ID, user.Email, user.Name, user.IsActive, ptrToNullTime(emailVerifiedAt), user.CreatedAt, user.UpdatedAt) if err != nil { return fmt.Errorf("insert user: %w", err) } - return nil + + _, err = tx.ExecContext(ctx, ` + INSERT INTO user_credentials (user_id, password_hash, last_login_at) + VALUES ($1, $2, $3) + `, user.ID, user.Password, nil) + if err != nil { + return fmt.Errorf("insert user credentials: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO user_security (user_id, login_attempts, locked_until) + VALUES ($1, $2, $3) + `, user.ID, user.FailedLoginAttempts, ptrToNullTime(user.LockedUntil)) + if err != nil { + return fmt.Errorf("insert user security: %w", err) + } + + if user.EmailVerifyToken != nil { + _, err = tx.ExecContext(ctx, ` + INSERT INTO user_tokens (user_id, token_type, token_hash, expires_at) + VALUES ($1, 'email_verification', $2, $3) + `, user.ID, *user.EmailVerifyToken, ptrToNullTime(user.EmailVerifyExpires)) + if err != nil { + return fmt.Errorf("insert email verify token: %w", err) + } + } + + if user.PasswordResetToken != nil { + _, err = tx.ExecContext(ctx, ` + INSERT INTO user_tokens (user_id, token_type, token_hash, expires_at) + VALUES ($1, 'password_reset', $2, $3) + `, user.ID, *user.PasswordResetToken, ptrToNullTime(user.PasswordResetExpires)) + if err != nil { + return fmt.Errorf("insert password reset token: %w", err) + } + } + + return tx.Commit() } +const userSelectColumns = `SELECT u.id, u.email, u.name, u.is_active, u.email_verified_at, u.created_at, u.updated_at, u.deleted_at, + uc.password_hash, uc.last_login_at, + us.login_attempts, us.locked_until` + +const userTokenNulls = `NULL::varchar, NULL::timestamptz, NULL::varchar, NULL::timestamptz` + func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.User, error) { - query := `SELECT id, email, password, name, is_active, created_at, updated_at, deleted_at FROM users WHERE id = $1 AND deleted_at IS NULL` - user := &entity.User{} - err := r.db.QueryRowContext(ctx, query, id).Scan(&user.ID, &user.Email, &user.Password, &user.Name, &user.IsActive, &user.CreatedAt, &user.UpdatedAt, &user.DeletedAt) + query := userSelectColumns + `, ` + userTokenNulls + ` +FROM users u +LEFT JOIN user_credentials uc ON u.id = uc.user_id +LEFT JOIN user_security us ON u.id = us.user_id +WHERE u.id = $1 AND u.deleted_at IS NULL` + row := r.db.QueryRowContext(ctx, query, id) + dbRow, err := scanUser(row) if err == sql.ErrNoRows { return nil, fmt.Errorf("user not found") } if err != nil { return nil, fmt.Errorf("get user: %w", err) } - return user, nil + return mapRowToEntity(dbRow), nil } func (r *userRepository) GetByEmail(ctx context.Context, email string) (*entity.User, error) { - query := `SELECT id, email, password, name, is_active, created_at, updated_at, deleted_at FROM users WHERE email = $1 AND deleted_at IS NULL` - user := &entity.User{} - err := r.db.QueryRowContext(ctx, query, email).Scan(&user.ID, &user.Email, &user.Password, &user.Name, &user.IsActive, &user.CreatedAt, &user.UpdatedAt, &user.DeletedAt) + query := userSelectColumns + `, ` + userTokenNulls + ` +FROM users u +LEFT JOIN user_credentials uc ON u.id = uc.user_id +LEFT JOIN user_security us ON u.id = us.user_id +WHERE u.email = $1 AND u.deleted_at IS NULL` + row := r.db.QueryRowContext(ctx, query, email) + dbRow, err := scanUser(row) if err == sql.ErrNoRows { return nil, fmt.Errorf("user not found") } if err != nil { return nil, fmt.Errorf("get user by email: %w", err) } - return user, nil + return mapRowToEntity(dbRow), nil +} + +func (r *userRepository) GetByVerifyToken(ctx context.Context, token string) (*entity.User, error) { + query := userSelectColumns + `, + ut.token_hash, ut.expires_at, + NULL::varchar, NULL::timestamptz +FROM users u +LEFT JOIN user_credentials uc ON u.id = uc.user_id +LEFT JOIN user_security us ON u.id = us.user_id +INNER JOIN user_tokens ut ON u.id = ut.user_id AND ut.token_hash = $1 AND ut.token_type = 'email_verification' AND ut.consumed_at IS NULL AND (ut.expires_at IS NULL OR ut.expires_at > NOW()) +WHERE u.deleted_at IS NULL` + row := r.db.QueryRowContext(ctx, query, token) + dbRow, err := scanUser(row) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("user not found") + } + if err != nil { + return nil, fmt.Errorf("get user by verify token: %w", err) + } + return mapRowToEntity(dbRow), nil +} + +func (r *userRepository) GetByResetToken(ctx context.Context, token string) (*entity.User, error) { + query := userSelectColumns + `, + NULL::varchar, NULL::timestamptz, + ut.token_hash, ut.expires_at +FROM users u +LEFT JOIN user_credentials uc ON u.id = uc.user_id +LEFT JOIN user_security us ON u.id = us.user_id +INNER JOIN user_tokens ut ON u.id = ut.user_id AND ut.token_hash = $1 AND ut.token_type = 'password_reset' AND ut.consumed_at IS NULL AND (ut.expires_at IS NULL OR ut.expires_at > NOW()) +WHERE u.deleted_at IS NULL` + row := r.db.QueryRowContext(ctx, query, token) + dbRow, err := scanUser(row) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("user not found") + } + if err != nil { + return nil, fmt.Errorf("get user by reset token: %w", err) + } + return mapRowToEntity(dbRow), nil } func (r *userRepository) Update(ctx context.Context, user *entity.User) error { - query := `UPDATE users SET email = $2, password = $3, name = $4, is_active = $5, updated_at = $6 WHERE id = $1 AND deleted_at IS NULL` - result, err := r.db.ExecContext(ctx, query, user.ID, user.Email, user.Password, user.Name, user.IsActive, user.UpdatedAt) + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + result, err := tx.ExecContext(ctx, ` + UPDATE users SET + email = $1, + name = $2, + is_active = $3, + email_verified_at = CASE WHEN $4 THEN COALESCE(email_verified_at, NOW()) ELSE NULL END, + updated_at = NOW() + WHERE id = $5 AND deleted_at IS NULL + `, user.Email, user.Name, user.IsActive, user.EmailVerified, user.ID) if err != nil { return fmt.Errorf("update user: %w", err) } @@ -63,5 +225,87 @@ func (r *userRepository) Update(ctx context.Context, user *entity.User) error { if rows == 0 { return fmt.Errorf("user not found") } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO user_credentials (user_id, password_hash, last_login_at) + VALUES ($1, $2, $3) + ON CONFLICT (user_id) DO UPDATE SET password_hash = EXCLUDED.password_hash, last_login_at = EXCLUDED.last_login_at + `, user.ID, user.Password, nil) + if err != nil { + return fmt.Errorf("upsert user credentials: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO user_security (user_id, login_attempts, locked_until) + VALUES ($1, $2, $3) + ON CONFLICT (user_id) DO UPDATE SET login_attempts = EXCLUDED.login_attempts, locked_until = EXCLUDED.locked_until + `, user.ID, user.FailedLoginAttempts, ptrToNullTime(user.LockedUntil)) + if err != nil { + return fmt.Errorf("upsert user security: %w", err) + } + + if user.EmailVerifyToken != nil { + _, err = tx.ExecContext(ctx, ` + INSERT INTO user_tokens (user_id, token_type, token_hash, expires_at) + VALUES ($1, 'email_verification', $2, $3) + `, user.ID, *user.EmailVerifyToken, ptrToNullTime(user.EmailVerifyExpires)) + if err != nil { + return fmt.Errorf("insert email verify token: %w", err) + } + } + + if user.PasswordResetToken != nil { + _, err = tx.ExecContext(ctx, ` + INSERT INTO user_tokens (user_id, token_type, token_hash, expires_at) + VALUES ($1, 'password_reset', $2, $3) + `, user.ID, *user.PasswordResetToken, ptrToNullTime(user.PasswordResetExpires)) + if err != nil { + return fmt.Errorf("insert password reset token: %w", err) + } + } + + return tx.Commit() +} + +func mapRowToEntity(row userRow) *entity.User { + return &entity.User{ + Entity: domain.Entity{ + ID: row.ID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + DeletedAt: nullTimeToPtr(row.DeletedAt), + }, + Email: row.Email, + Password: row.PasswordHash.String, + Name: row.Name, + IsActive: row.IsActive, + FailedLoginAttempts: int(row.LoginAttempts.Int32), + LockedUntil: nullTimeToPtr(row.LockedUntil), + EmailVerified: row.EmailVerifiedAt.Valid, + EmailVerifyToken: nullStringToPtr(row.EmailVerifyToken), + EmailVerifyExpires: nullTimeToPtr(row.EmailVerifyExpires), + PasswordResetToken: nullStringToPtr(row.PasswordResetToken), + PasswordResetExpires: nullTimeToPtr(row.PasswordResetExpires), + } +} + +func nullTimeToPtr(nt sql.NullTime) *time.Time { + if nt.Valid { + return &nt.Time + } + return nil +} + +func nullStringToPtr(ns sql.NullString) *string { + if ns.Valid { + return &ns.String + } return nil } + +func ptrToNullTime(t *time.Time) sql.NullTime { + if t != nil { + return sql.NullTime{Time: *t, Valid: true} + } + return sql.NullTime{Valid: false} +} diff --git a/internal/authentication/interfaces/http/handlers.go b/internal/authentication/interfaces/http/handlers.go index 82b3683..7da4d66 100644 --- a/internal/authentication/interfaces/http/handlers.go +++ b/internal/authentication/interfaces/http/handlers.go @@ -2,11 +2,15 @@ package http import ( "encoding/json" + "errors" "net/http" "time" + "github.com/IDTS-LAB/go-codebase/internal/authentication/application/command" "github.com/IDTS-LAB/go-codebase/internal/authentication/application/dto" - "github.com/IDTS-LAB/go-codebase/internal/authentication/application/service" + "github.com/IDTS-LAB/go-codebase/internal/authentication/application/query" + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" "github.com/IDTS-LAB/go-codebase/internal/shared/utils" "github.com/IDTS-LAB/go-codebase/internal/shared/validator" @@ -14,12 +18,13 @@ import ( ) type Handler struct { - svc *service.AuthenticationService - validator *validator.Validator + commandBus cqrs.CommandBus + queryBus cqrs.QueryBus + validator *validator.Validator } -func NewHandler(svc *service.AuthenticationService, v *validator.Validator) *Handler { - return &Handler{svc: svc, validator: v} +func NewHandler(commandBus cqrs.CommandBus, queryBus cqrs.QueryBus, v *validator.Validator) *Handler { + return &Handler{commandBus: commandBus, queryBus: queryBus, validator: v} } // Register godoc @@ -29,10 +34,10 @@ func NewHandler(svc *service.AuthenticationService, v *validator.Validator) *Han // @Accept json // @Produce json // @Param request body dto.RegisterRequest true "Registration details" -// @Success 201 {object} utils.SuccessResponse{data=dto.TokenResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 409 {object} utils.ErrorResponse -// @Failure 500 {object} utils.ErrorResponse +// @Success 201 {object} utils.APIResponse{data=dto.TokenResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 409 {object} utils.APIResponse +// @Failure 500 {object} utils.APIResponse // @Router /auth/register [post] func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { var req dto.RegisterRequest @@ -44,27 +49,16 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, err.Error()) return } - user, err := h.svc.Register(r.Context(), req.Email, req.Password, req.Name) - if err != nil { - switch err { - case service.ErrEmailAlreadyExists: - utils.RespondConflict(w, "email already registered") - default: - utils.RespondInternalError(w, "failed to register user") - } - return - } - tokens, err := h.svc.GenerateTokens(r.Context(), user) - if err != nil { - utils.RespondInternalError(w, "failed to generate tokens") + _, err := h.commandBus.Dispatch(r.Context(), command.RegisterUserCommand{ + Email: req.Email, + Password: req.Password, + Name: req.Name, + }) + if err != nil && errors.Is(err, command.ErrEmailAlreadyExists) { + utils.RespondConflict(w, "email already registered") return } - utils.RespondCreated(w, dto.TokenResponse{ - AccessToken: tokens.AccessToken, - RefreshToken: tokens.RefreshToken, - ExpiresIn: tokens.ExpiresIn, - TokenType: "Bearer", - }) + utils.HandleCreated(w, r, dto.MessageResponse{Message: "user registered successfully. Check your email for verification."}, err) } // Login godoc @@ -74,9 +68,9 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { // @Accept json // @Produce json // @Param request body dto.LoginRequest true "Login credentials" -// @Success 200 {object} utils.SuccessResponse{data=dto.TokenResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 401 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.TokenResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 401 {object} utils.APIResponse // @Router /auth/login [post] func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { var req dto.LoginRequest @@ -88,31 +82,39 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, err.Error()) return } - user, err := h.svc.Login(r.Context(), req.Email, req.Password) + userResp, err := h.queryBus.Ask(r.Context(), query.LoginQuery{ + Email: req.Email, + Password: req.Password, + }) if err != nil { - switch err { - case service.ErrInvalidCredentials: - utils.RespondError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid email or password") - case service.ErrAccountDisabled: - utils.RespondError(w, http.StatusUnauthorized, "UNAUTHORIZED", "account is disabled") - case service.ErrAccountLocked: - utils.RespondError(w, http.StatusForbidden, "ACCOUNT_LOCKED", "account is temporarily locked due to too many failed attempts") + switch { + case errors.Is(err, query.ErrInvalidCredentials): + utils.RespondUnauthorized(w, "invalid email or password") + case errors.Is(err, query.ErrAccountDisabled): + utils.RespondUnauthorized(w, "account is disabled") + case errors.Is(err, query.ErrAccountLocked): + utils.RespondForbidden(w, "ACCOUNT_LOCKED", "account is temporarily locked due to too many failed attempts") + case errors.Is(err, query.ErrEmailNotVerified): + utils.RespondForbidden(w, "EMAIL_NOT_VERIFIED", "email is not verified") default: utils.RespondInternalError(w, "failed to login") } return } - tokens, err := h.svc.GenerateTokens(r.Context(), user) + tokenResp, err := h.commandBus.Dispatch(r.Context(), command.GenerateTokensCommand{ + User: userResp.(*entity.User), + }) if err != nil { utils.RespondInternalError(w, "failed to generate tokens") return } - utils.RespondSuccess(w, dto.TokenResponse{ + tokens := tokenResp.(*command.TokenPair) + utils.Handle(w, r, dto.TokenResponse{ AccessToken: tokens.AccessToken, RefreshToken: tokens.RefreshToken, ExpiresIn: tokens.ExpiresIn, TokenType: "Bearer", - }) + }, nil) } // RefreshToken godoc @@ -122,9 +124,9 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { // @Accept json // @Produce json // @Param request body dto.RefreshRequest true "Refresh token" -// @Success 200 {object} utils.SuccessResponse{data=dto.TokenResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 401 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.TokenResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 401 {object} utils.APIResponse // @Router /auth/refresh [post] func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) { var req dto.RefreshRequest @@ -136,22 +138,24 @@ func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, err.Error()) return } - tokens, err := h.svc.RefreshToken(r.Context(), req.RefreshToken) + resp, err := h.commandBus.Dispatch(r.Context(), command.RefreshTokenCommand{ + RefreshToken: req.RefreshToken, + }) + if err != nil && errors.Is(err, command.ErrInvalidRefreshToken) { + utils.RespondUnauthorized(w, "invalid or expired refresh token") + return + } if err != nil { - switch err { - case service.ErrInvalidRefreshToken: - utils.RespondError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired refresh token") - default: - utils.RespondInternalError(w, "failed to refresh token") - } + utils.RespondInternalError(w, "failed to refresh token") return } - utils.RespondSuccess(w, dto.TokenResponse{ + tokens := resp.(*command.TokenPair) + utils.Handle(w, r, dto.TokenResponse{ AccessToken: tokens.AccessToken, RefreshToken: tokens.RefreshToken, ExpiresIn: tokens.ExpiresIn, TokenType: "Bearer", - }) + }, nil) } // Logout godoc @@ -161,8 +165,8 @@ func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) { // @Accept json // @Produce json // @Param request body dto.RefreshRequest true "Refresh token to revoke" -// @Success 200 {object} utils.SuccessResponse{data=dto.MessageResponse} -// @Failure 400 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.MessageResponse} +// @Failure 400 {object} utils.APIResponse // @Router /auth/logout [post] func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { var req dto.RefreshRequest @@ -174,11 +178,12 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { if jti := r.Context().Value("access_token_jti"); jti != nil { accessTokenJTI, _ = jti.(string) } - if err := h.svc.Logout(r.Context(), req.RefreshToken, accessTokenJTI, 15*time.Minute); err != nil { - utils.RespondInternalError(w, "failed to logout") - return - } - utils.RespondSuccess(w, dto.MessageResponse{Message: "logged out successfully"}) + _, err := h.commandBus.Dispatch(r.Context(), command.LogoutCommand{ + RefreshToken: req.RefreshToken, + AccessTokenJTI: accessTokenJTI, + AccessTokenTTL: 15 * time.Minute, + }) + utils.Handle(w, r, dto.MessageResponse{Message: "logged out successfully"}, err) } // LogoutAllSessions godoc @@ -186,8 +191,8 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { // @Description Revoke all refresh tokens for the current user // @Tags authentication // @Produce json -// @Success 200 {object} utils.SuccessResponse{data=dto.MessageResponse} -// @Failure 401 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.MessageResponse} +// @Failure 401 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/logout-all [post] func (h *Handler) LogoutAll(w http.ResponseWriter, r *http.Request) { @@ -201,11 +206,8 @@ func (h *Handler) LogoutAll(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, "invalid user ID") return } - if err := h.svc.LogoutAll(r.Context(), uid); err != nil { - utils.RespondInternalError(w, "failed to logout all sessions") - return - } - utils.RespondSuccess(w, dto.MessageResponse{Message: "all sessions terminated"}) + _, err = h.commandBus.Dispatch(r.Context(), command.LogoutAllCommand{UserID: uid}) + utils.Handle(w, r, dto.MessageResponse{Message: "all sessions terminated"}, err) } // Me godoc @@ -213,8 +215,8 @@ func (h *Handler) LogoutAll(w http.ResponseWriter, r *http.Request) { // @Description Get the authenticated user's profile // @Tags authentication // @Produce json -// @Success 200 {object} utils.SuccessResponse{data=dto.UserResponse} -// @Failure 401 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.UserResponse} +// @Failure 401 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/me [get] func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { @@ -223,10 +225,109 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { utils.RespondError(w, http.StatusUnauthorized, "UNAUTHORIZED", "user not authenticated") return } - utils.RespondSuccess(w, dto.UserResponse{ + utils.Handle(w, r, dto.UserResponse{ ID: userID, Email: middleware.GetUserEmail(r.Context()), Name: "", IsActive: true, + }, nil) +} + +// VerifyEmail godoc +// @Summary Verify email address +// @Description Verify user email with token from email +// @Tags authentication +// @Param token query string true "Verification token" +// @Success 200 {object} utils.APIResponse +// @Failure 400 {object} utils.APIResponse +// @Router /auth/verify-email [get] +func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + utils.RespondBadRequest(w, "token is required") + return + } + _, err := h.commandBus.Dispatch(r.Context(), command.VerifyEmailCommand{Token: token}) + if err != nil && (errors.Is(err, command.ErrInvalidVerifyToken) || errors.Is(err, command.ErrVerifyTokenExpired)) { + utils.RespondBadRequest(w, err.Error()) + return + } + utils.Handle(w, r, map[string]string{"message": "email verified successfully"}, err) +} + +// ForgotPassword godoc +// @Summary Request password reset +// @Description Send password reset email +// @Tags authentication +// @Accept json +// @Produce json +// @Param request body dto.ForgotPasswordRequest true "Email address" +// @Success 200 {object} utils.APIResponse +// @Router /auth/forgot-password [post] +func (h *Handler) ForgotPassword(w http.ResponseWriter, r *http.Request) { + var req dto.ForgotPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondBadRequest(w, "invalid request body") + return + } + if err := h.validator.Validate(req); err != nil { + utils.RespondBadRequest(w, err.Error()) + return + } + _, _ = h.commandBus.Dispatch(r.Context(), command.ForgotPasswordCommand{Email: req.Email}) + utils.RespondSuccess(w, map[string]string{"message": "if the email exists, a reset link has been sent"}) +} + +// ResetPassword godoc +// @Summary Reset password +// @Description Reset password with token from email +// @Tags authentication +// @Accept json +// @Produce json +// @Param request body dto.ResetPasswordRequest true "Token and new password" +// @Success 200 {object} utils.APIResponse +// @Failure 400 {object} utils.APIResponse +// @Router /auth/reset-password [post] +func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) { + var req dto.ResetPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondBadRequest(w, "invalid request body") + return + } + if err := h.validator.Validate(req); err != nil { + utils.RespondBadRequest(w, err.Error()) + return + } + _, err := h.commandBus.Dispatch(r.Context(), command.ResetPasswordCommand{ + Token: req.Token, + NewPassword: req.NewPassword, }) -} \ No newline at end of file + if err != nil && (errors.Is(err, command.ErrInvalidResetToken) || errors.Is(err, command.ErrResetTokenExpired)) { + utils.RespondBadRequest(w, err.Error()) + return + } + utils.Handle(w, r, map[string]string{"message": "password reset successfully"}, err) +} + +// ResendVerification godoc +// @Summary Resend verification email +// @Description Resend email verification link +// @Tags authentication +// @Accept json +// @Produce json +// @Param request body dto.ResendVerificationRequest true "Email address" +// @Success 200 {object} utils.APIResponse +// @Router /auth/resend-verification [post] +func (h *Handler) ResendVerification(w http.ResponseWriter, r *http.Request) { + var req dto.ResendVerificationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondBadRequest(w, "invalid request body") + return + } + if err := h.validator.Validate(req); err != nil { + utils.RespondBadRequest(w, err.Error()) + return + } + _, _ = h.commandBus.Dispatch(r.Context(), command.ResendVerificationCommand{Email: req.Email}) + utils.RespondSuccess(w, map[string]string{"message": "if the email exists, a verification link has been sent"}) +} diff --git a/internal/authentication/interfaces/http/handlers_test.go b/internal/authentication/interfaces/http/handlers_test.go new file mode 100644 index 0000000..45a4e63 --- /dev/null +++ b/internal/authentication/interfaces/http/handlers_test.go @@ -0,0 +1,250 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/IDTS-LAB/go-codebase/internal/authentication/application/command" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/shared/validator" +) + +type mockHandler struct { + result any + err error +} + +func (h *mockHandler) Handle(ctx context.Context, _ any) (any, error) { + return h.result, h.err +} + +func TestVerifyEmail_MissingToken(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + r := httptest.NewRequest(http.MethodGet, "/auth/verify-email", nil) + w := httptest.NewRecorder() + h.VerifyEmail(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } + if resp["error"].(map[string]interface{})["code"] != "VALIDATION_ERROR" { + t.Errorf("expected VALIDATION_ERROR, got %v", resp["error"].(map[string]interface{})["code"]) + } +} + +func TestVerifyEmail_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.VerifyEmailCommand{}, &mockHandler{ + result: map[string]string{"message": "email verified successfully"}, + }) + + r := httptest.NewRequest(http.MethodGet, "/auth/verify-email?token=valid-token", nil) + w := httptest.NewRecorder() + h.VerifyEmail(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if !resp["success"].(bool) { + t.Error("expected success true") + } + if resp["data"] == nil { + t.Error("expected data not null") + } + if resp["meta"] != nil { + t.Error("expected meta null") + } +} + +func TestVerifyEmail_InvalidToken(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.VerifyEmailCommand{}, &mockHandler{ + err: command.ErrInvalidVerifyToken, + }) + + r := httptest.NewRequest(http.MethodGet, "/auth/verify-email?token=does-not-exist", nil) + w := httptest.NewRecorder() + h.VerifyEmail(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } + if resp["error"].(map[string]interface{})["code"] != "VALIDATION_ERROR" { + t.Errorf("expected VALIDATION_ERROR, got %v", resp["error"].(map[string]interface{})["code"]) + } +} + +func TestForgotPassword_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.ForgotPasswordCommand{}, &mockHandler{}) + + body := map[string]string{"email": "forgot@example.com"} + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/forgot-password", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ForgotPassword(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if !resp["success"].(bool) { + t.Error("expected success true") + } + if resp["data"] == nil { + t.Error("expected data not null") + } + if resp["meta"] != nil { + t.Error("expected meta null") + } +} + +func TestForgotPassword_InvalidBody(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + r := httptest.NewRequest(http.MethodPost, "/auth/forgot-password", bytes.NewReader([]byte("{not json"))) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ForgotPassword(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } + if resp["error"].(map[string]interface{})["code"] != "VALIDATION_ERROR" { + t.Errorf("expected VALIDATION_ERROR, got %v", resp["error"].(map[string]interface{})["code"]) + } +} + +func TestResetPassword_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.ResetPasswordCommand{}, &mockHandler{}) + + body := map[string]string{"token": "valid-reset-token", "new_password": "newpassword123"} + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/reset-password", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ResetPassword(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if !resp["success"].(bool) { + t.Error("expected success true") + } + if resp["data"] == nil { + t.Error("expected data not null") + } + if resp["meta"] != nil { + t.Error("expected meta null") + } +} + +func TestResetPassword_InvalidBody(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + r := httptest.NewRequest(http.MethodPost, "/auth/reset-password", bytes.NewReader([]byte("{not json"))) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ResetPassword(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["success"].(bool) { + t.Error("expected success false") + } + if resp["data"] != nil { + t.Error("expected data null") + } + if resp["error"].(map[string]interface{})["code"] != "VALIDATION_ERROR" { + t.Errorf("expected VALIDATION_ERROR, got %v", resp["error"].(map[string]interface{})["code"]) + } +} + +func TestResendVerification_Success(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + h := NewHandler(cmdBus, qBus, validator.New()) + + cmdBus.Register(command.ResendVerificationCommand{}, &mockHandler{}) + + body := map[string]string{"email": "resend@example.com"} + b, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, "/auth/resend-verification", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ResendVerification(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if !resp["success"].(bool) { + t.Error("expected success true") + } + if resp["data"] == nil { + t.Error("expected data not null") + } + if resp["meta"] != nil { + t.Error("expected meta null") + } +} diff --git a/internal/authentication/interfaces/http/routes.go b/internal/authentication/interfaces/http/routes.go index ee482d8..33e4375 100644 --- a/internal/authentication/interfaces/http/routes.go +++ b/internal/authentication/interfaces/http/routes.go @@ -13,6 +13,10 @@ func NewRouter(handler *Handler, authMiddleware func(http.Handler) http.Handler) r.Post("/login", handler.Login) r.Post("/refresh", handler.RefreshToken) r.Post("/logout", handler.Logout) + r.Get("/verify-email", handler.VerifyEmail) + r.Post("/forgot-password", handler.ForgotPassword) + r.Post("/reset-password", handler.ResetPassword) + r.Post("/resend-verification", handler.ResendVerification) r.Group(func(r chi.Router) { r.Use(authMiddleware) diff --git a/internal/authentication/module.go b/internal/authentication/module.go index 3b19d10..3c03ec2 100644 --- a/internal/authentication/module.go +++ b/internal/authentication/module.go @@ -1,9 +1,16 @@ package authentication import ( - "github.com/IDTS-LAB/go-codebase/internal/authentication/application/service" + "github.com/IDTS-LAB/go-codebase/internal/authentication/application/command" + "github.com/IDTS-LAB/go-codebase/internal/authentication/application/query" + authEvent "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/event" + "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/authentication/infrastructure/eventbus" "github.com/IDTS-LAB/go-codebase/internal/authentication/infrastructure/persistence" httpHandler "github.com/IDTS-LAB/go-codebase/internal/authentication/interfaces/http" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" "go.uber.org/fx" ) @@ -11,7 +18,39 @@ var Module = fx.Module("authentication", fx.Provide( persistence.NewUserRepository, persistence.NewRefreshTokenRepository, - service.NewAuthenticationService, + eventbus.NewEmailHandler, httpHandler.NewHandler, ), + + fx.Invoke( + registerHandlers, + func() { + events.Register(authEvent.UserRegisteredEvent, func() interface{} { return &authEvent.UserRegistered{} }) + events.Register(authEvent.EmailVerifiedEvent, func() interface{} { return &authEvent.EmailVerified{} }) + events.Register(authEvent.PasswordResetRequestedEvent, func() interface{} { return &authEvent.PasswordResetRequested{} }) + }, + ), ) + +func registerHandlers( + commandBus cqrs.CommandBus, + queryBus cqrs.QueryBus, + userRepo repository.UserRepository, + refreshRepo repository.RefreshTokenRepository, + tokenService domain.TokenService, + bus events.EventBus, +) { + generateTokensHandler := command.NewGenerateTokensHandler(refreshRepo, tokenService) + + commandBus.Register(command.RegisterUserCommand{}, command.NewRegisterUserHandler(userRepo, bus)) + commandBus.Register(command.GenerateTokensCommand{}, generateTokensHandler) + commandBus.Register(command.RefreshTokenCommand{}, command.NewRefreshTokenHandler(refreshRepo, userRepo, generateTokensHandler)) + commandBus.Register(command.LogoutCommand{}, command.NewLogoutHandler(refreshRepo)) + commandBus.Register(command.LogoutAllCommand{}, command.NewLogoutAllHandler(refreshRepo)) + commandBus.Register(command.VerifyEmailCommand{}, command.NewVerifyEmailHandler(userRepo, bus)) + commandBus.Register(command.ForgotPasswordCommand{}, command.NewForgotPasswordHandler(userRepo, bus)) + commandBus.Register(command.ResetPasswordCommand{}, command.NewResetPasswordHandler(userRepo, refreshRepo)) + commandBus.Register(command.ResendVerificationCommand{}, command.NewResendVerificationHandler(userRepo, bus)) + + queryBus.Register(query.LoginQuery{}, query.NewLoginHandler(userRepo)) +} diff --git a/internal/authorization/application/command/assign_permission.go b/internal/authorization/application/command/assign_permission.go new file mode 100644 index 0000000..eb74a01 --- /dev/null +++ b/internal/authorization/application/command/assign_permission.go @@ -0,0 +1,41 @@ +package command + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/google/uuid" +) + +type AssignPermissionCommand struct { + RoleID uuid.UUID + PermissionID uuid.UUID +} + +type AssignPermissionHandler struct { + roleRepo repository.RoleRepository + permRepo repository.PermissionRepository + rolePermRepo repository.RolePermissionRepository + enforcer Enforcer +} + +func NewAssignPermissionHandler(roleRepo repository.RoleRepository, permRepo repository.PermissionRepository, rolePermRepo repository.RolePermissionRepository, enforcer Enforcer) *AssignPermissionHandler { + return &AssignPermissionHandler{roleRepo: roleRepo, permRepo: permRepo, rolePermRepo: rolePermRepo, enforcer: enforcer} +} + +func (h *AssignPermissionHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(AssignPermissionCommand) + _, err := h.roleRepo.GetByID(ctx, c.RoleID) + if err != nil { + return nil, err + } + _, err = h.permRepo.GetByID(ctx, c.PermissionID) + if err != nil { + return nil, err + } + if err := h.rolePermRepo.Assign(ctx, entity.NewRolePermission(c.RoleID, c.PermissionID)); err != nil { + return nil, err + } + return nil, h.enforcer.ReloadPolicies(ctx) +} diff --git a/internal/authorization/application/command/assign_role.go b/internal/authorization/application/command/assign_role.go new file mode 100644 index 0000000..7c2f718 --- /dev/null +++ b/internal/authorization/application/command/assign_role.go @@ -0,0 +1,42 @@ +package command + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/google/uuid" +) + +type Enforcer interface { + ReloadPolicies(ctx context.Context) error + ReloadUserPolicies(ctx context.Context, userID uuid.UUID) error + Enforce(userID uuid.UUID, resource, action string) (bool, error) +} + +type AssignRoleCommand struct { + UserID uuid.UUID + RoleID uuid.UUID +} + +type AssignRoleHandler struct { + roleRepo repository.RoleRepository + userRoleRepo repository.UserRoleRepository + enforcer Enforcer +} + +func NewAssignRoleHandler(roleRepo repository.RoleRepository, userRoleRepo repository.UserRoleRepository, enforcer Enforcer) *AssignRoleHandler { + return &AssignRoleHandler{roleRepo: roleRepo, userRoleRepo: userRoleRepo, enforcer: enforcer} +} + +func (h *AssignRoleHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(AssignRoleCommand) + _, err := h.roleRepo.GetByID(ctx, c.RoleID) + if err != nil { + return nil, err + } + if err := h.userRoleRepo.Assign(ctx, entity.NewUserRole(c.UserID, c.RoleID)); err != nil { + return nil, err + } + return nil, h.enforcer.ReloadUserPolicies(ctx, c.UserID) +} diff --git a/internal/authorization/application/command/create_permission.go b/internal/authorization/application/command/create_permission.go new file mode 100644 index 0000000..dd4c31a --- /dev/null +++ b/internal/authorization/application/command/create_permission.go @@ -0,0 +1,37 @@ +package command + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + coredomain "github.com/IDTS-LAB/go-codebase/internal/core/domain" +) + +type CreatePermissionCommand struct { + Name string + Description string + Resource string + Action string +} + +type CreatePermissionHandler struct { + permRepo repository.PermissionRepository +} + +func NewCreatePermissionHandler(permRepo repository.PermissionRepository) *CreatePermissionHandler { + return &CreatePermissionHandler{permRepo: permRepo} +} + +func (h *CreatePermissionHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(CreatePermissionCommand) + existing, _ := h.permRepo.GetByName(ctx, c.Name) + if existing != nil { + return nil, coredomain.ErrConflict + } + perm := entity.NewPermission(c.Name, c.Description, c.Resource, c.Action) + if err := h.permRepo.Create(ctx, perm); err != nil { + return nil, err + } + return perm, nil +} diff --git a/internal/authorization/application/command/create_role.go b/internal/authorization/application/command/create_role.go new file mode 100644 index 0000000..a0b4115 --- /dev/null +++ b/internal/authorization/application/command/create_role.go @@ -0,0 +1,35 @@ +package command + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + coredomain "github.com/IDTS-LAB/go-codebase/internal/core/domain" +) + +type CreateRoleCommand struct { + Name string + Description string +} + +type CreateRoleHandler struct { + roleRepo repository.RoleRepository +} + +func NewCreateRoleHandler(roleRepo repository.RoleRepository) *CreateRoleHandler { + return &CreateRoleHandler{roleRepo: roleRepo} +} + +func (h *CreateRoleHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(CreateRoleCommand) + existing, _ := h.roleRepo.GetByName(ctx, c.Name) + if existing != nil { + return nil, coredomain.ErrConflict + } + role := entity.NewRole(c.Name, c.Description) + if err := h.roleRepo.Create(ctx, role); err != nil { + return nil, err + } + return role, nil +} diff --git a/internal/authorization/application/command/delete_permission.go b/internal/authorization/application/command/delete_permission.go new file mode 100644 index 0000000..171a39e --- /dev/null +++ b/internal/authorization/application/command/delete_permission.go @@ -0,0 +1,25 @@ +package command + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/google/uuid" +) + +type DeletePermissionCommand struct { + ID uuid.UUID +} + +type DeletePermissionHandler struct { + permRepo repository.PermissionRepository +} + +func NewDeletePermissionHandler(permRepo repository.PermissionRepository) *DeletePermissionHandler { + return &DeletePermissionHandler{permRepo: permRepo} +} + +func (h *DeletePermissionHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(DeletePermissionCommand) + return nil, h.permRepo.Delete(ctx, c.ID) +} diff --git a/internal/authorization/application/command/delete_role.go b/internal/authorization/application/command/delete_role.go new file mode 100644 index 0000000..d5ce011 --- /dev/null +++ b/internal/authorization/application/command/delete_role.go @@ -0,0 +1,25 @@ +package command + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/google/uuid" +) + +type DeleteRoleCommand struct { + ID uuid.UUID +} + +type DeleteRoleHandler struct { + roleRepo repository.RoleRepository +} + +func NewDeleteRoleHandler(roleRepo repository.RoleRepository) *DeleteRoleHandler { + return &DeleteRoleHandler{roleRepo: roleRepo} +} + +func (h *DeleteRoleHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(DeleteRoleCommand) + return nil, h.roleRepo.Delete(ctx, c.ID) +} diff --git a/internal/authorization/application/command/unassign_permission.go b/internal/authorization/application/command/unassign_permission.go new file mode 100644 index 0000000..535c559 --- /dev/null +++ b/internal/authorization/application/command/unassign_permission.go @@ -0,0 +1,30 @@ +package command + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/google/uuid" +) + +type UnassignPermissionCommand struct { + RoleID uuid.UUID + PermissionID uuid.UUID +} + +type UnassignPermissionHandler struct { + rolePermRepo repository.RolePermissionRepository + enforcer Enforcer +} + +func NewUnassignPermissionHandler(rolePermRepo repository.RolePermissionRepository, enforcer Enforcer) *UnassignPermissionHandler { + return &UnassignPermissionHandler{rolePermRepo: rolePermRepo, enforcer: enforcer} +} + +func (h *UnassignPermissionHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(UnassignPermissionCommand) + if err := h.rolePermRepo.Remove(ctx, c.RoleID, c.PermissionID); err != nil { + return nil, err + } + return nil, h.enforcer.ReloadPolicies(ctx) +} diff --git a/internal/authorization/application/command/unassign_role.go b/internal/authorization/application/command/unassign_role.go new file mode 100644 index 0000000..673c90b --- /dev/null +++ b/internal/authorization/application/command/unassign_role.go @@ -0,0 +1,30 @@ +package command + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/google/uuid" +) + +type UnassignRoleCommand struct { + UserID uuid.UUID + RoleID uuid.UUID +} + +type UnassignRoleHandler struct { + userRoleRepo repository.UserRoleRepository + enforcer Enforcer +} + +func NewUnassignRoleHandler(userRoleRepo repository.UserRoleRepository, enforcer Enforcer) *UnassignRoleHandler { + return &UnassignRoleHandler{userRoleRepo: userRoleRepo, enforcer: enforcer} +} + +func (h *UnassignRoleHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(UnassignRoleCommand) + if err := h.userRoleRepo.Remove(ctx, c.UserID, c.RoleID); err != nil { + return nil, err + } + return nil, h.enforcer.ReloadUserPolicies(ctx, c.UserID) +} diff --git a/internal/authorization/application/command/update_permission.go b/internal/authorization/application/command/update_permission.go new file mode 100644 index 0000000..fb7a243 --- /dev/null +++ b/internal/authorization/application/command/update_permission.go @@ -0,0 +1,49 @@ +package command + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/google/uuid" +) + +type UpdatePermissionCommand struct { + ID uuid.UUID + Name string + Description string + Resource string + Action string +} + +type UpdatePermissionHandler struct { + permRepo repository.PermissionRepository +} + +func NewUpdatePermissionHandler(permRepo repository.PermissionRepository) *UpdatePermissionHandler { + return &UpdatePermissionHandler{permRepo: permRepo} +} + +func (h *UpdatePermissionHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(UpdatePermissionCommand) + perm, err := h.permRepo.GetByID(ctx, c.ID) + if err != nil { + return nil, err + } + if c.Name != "" { + perm.Name = c.Name + } + if c.Description != "" { + perm.Description = c.Description + } + if c.Resource != "" { + perm.Resource = c.Resource + } + if c.Action != "" { + perm.Action = c.Action + } + perm.Touch() + if err := h.permRepo.Update(ctx, perm); err != nil { + return nil, err + } + return perm, nil +} diff --git a/internal/authorization/application/command/update_role.go b/internal/authorization/application/command/update_role.go new file mode 100644 index 0000000..c45f294 --- /dev/null +++ b/internal/authorization/application/command/update_role.go @@ -0,0 +1,41 @@ +package command + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/google/uuid" +) + +type UpdateRoleCommand struct { + ID uuid.UUID + Name string + Description string +} + +type UpdateRoleHandler struct { + roleRepo repository.RoleRepository +} + +func NewUpdateRoleHandler(roleRepo repository.RoleRepository) *UpdateRoleHandler { + return &UpdateRoleHandler{roleRepo: roleRepo} +} + +func (h *UpdateRoleHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(UpdateRoleCommand) + role, err := h.roleRepo.GetByID(ctx, c.ID) + if err != nil { + return nil, err + } + if c.Name != "" { + role.Name = c.Name + } + if c.Description != "" { + role.Description = c.Description + } + role.Touch() + if err := h.roleRepo.Update(ctx, role); err != nil { + return nil, err + } + return role, nil +} diff --git a/internal/authorization/application/provider.go b/internal/authorization/application/provider.go new file mode 100644 index 0000000..742f8e5 --- /dev/null +++ b/internal/authorization/application/provider.go @@ -0,0 +1,29 @@ +package application + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/authorization/public" + "github.com/google/uuid" +) + +type authorizationProvider struct { + userRoleRepo repository.UserRoleRepository +} + +func NewAuthorizationProvider(userRoleRepo repository.UserRoleRepository) public.AuthorizationProvider { + return &authorizationProvider{userRoleRepo: userRoleRepo} +} + +func (p *authorizationProvider) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]string, error) { + roles, err := p.userRoleRepo.GetRolesByUserID(ctx, userID) + if err != nil { + return nil, err + } + names := make([]string, len(roles)) + for i, r := range roles { + names[i] = r.Name + } + return names, nil +} diff --git a/internal/authorization/application/query/check_permission.go b/internal/authorization/application/query/check_permission.go new file mode 100644 index 0000000..7e2da09 --- /dev/null +++ b/internal/authorization/application/query/check_permission.go @@ -0,0 +1,30 @@ +package query + +import ( + "context" + + "github.com/google/uuid" +) + +type CheckPermissionQuery struct { + UserID uuid.UUID + Resource string + Action string +} + +type CheckPermissionHandler struct { + enforcer Enforcer +} + +type Enforcer interface { + Enforce(userID uuid.UUID, resource, action string) (bool, error) +} + +func NewCheckPermissionHandler(enforcer Enforcer) *CheckPermissionHandler { + return &CheckPermissionHandler{enforcer: enforcer} +} + +func (h *CheckPermissionHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(CheckPermissionQuery) + return h.enforcer.Enforce(q.UserID, q.Resource, q.Action) +} diff --git a/internal/authorization/application/query/get_permission.go b/internal/authorization/application/query/get_permission.go new file mode 100644 index 0000000..12ed114 --- /dev/null +++ b/internal/authorization/application/query/get_permission.go @@ -0,0 +1,25 @@ +package query + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/google/uuid" +) + +type GetPermissionQuery struct { + ID uuid.UUID +} + +type GetPermissionHandler struct { + permRepo repository.PermissionRepository +} + +func NewGetPermissionHandler(permRepo repository.PermissionRepository) *GetPermissionHandler { + return &GetPermissionHandler{permRepo: permRepo} +} + +func (h *GetPermissionHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(GetPermissionQuery) + return h.permRepo.GetByID(ctx, q.ID) +} diff --git a/internal/authorization/application/query/get_role.go b/internal/authorization/application/query/get_role.go new file mode 100644 index 0000000..a7460a1 --- /dev/null +++ b/internal/authorization/application/query/get_role.go @@ -0,0 +1,25 @@ +package query + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/google/uuid" +) + +type GetRoleQuery struct { + ID uuid.UUID +} + +type GetRoleHandler struct { + roleRepo repository.RoleRepository +} + +func NewGetRoleHandler(roleRepo repository.RoleRepository) *GetRoleHandler { + return &GetRoleHandler{roleRepo: roleRepo} +} + +func (h *GetRoleHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(GetRoleQuery) + return h.roleRepo.GetByID(ctx, q.ID) +} diff --git a/internal/authorization/application/query/get_role_permissions.go b/internal/authorization/application/query/get_role_permissions.go new file mode 100644 index 0000000..49201c1 --- /dev/null +++ b/internal/authorization/application/query/get_role_permissions.go @@ -0,0 +1,25 @@ +package query + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/google/uuid" +) + +type GetRolePermissionsQuery struct { + RoleID uuid.UUID +} + +type GetRolePermissionsHandler struct { + rolePermRepo repository.RolePermissionRepository +} + +func NewGetRolePermissionsHandler(rolePermRepo repository.RolePermissionRepository) *GetRolePermissionsHandler { + return &GetRolePermissionsHandler{rolePermRepo: rolePermRepo} +} + +func (h *GetRolePermissionsHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(GetRolePermissionsQuery) + return h.rolePermRepo.GetPermissionsByRoleID(ctx, q.RoleID) +} diff --git a/internal/authorization/application/query/get_user_roles.go b/internal/authorization/application/query/get_user_roles.go new file mode 100644 index 0000000..61f4192 --- /dev/null +++ b/internal/authorization/application/query/get_user_roles.go @@ -0,0 +1,25 @@ +package query + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/google/uuid" +) + +type GetUserRolesQuery struct { + UserID uuid.UUID +} + +type GetUserRolesHandler struct { + userRoleRepo repository.UserRoleRepository +} + +func NewGetUserRolesHandler(userRoleRepo repository.UserRoleRepository) *GetUserRolesHandler { + return &GetUserRolesHandler{userRoleRepo: userRoleRepo} +} + +func (h *GetUserRolesHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(GetUserRolesQuery) + return h.userRoleRepo.GetRolesByUserID(ctx, q.UserID) +} diff --git a/internal/authorization/application/query/list_permissions.go b/internal/authorization/application/query/list_permissions.go new file mode 100644 index 0000000..27056c7 --- /dev/null +++ b/internal/authorization/application/query/list_permissions.go @@ -0,0 +1,44 @@ +package query + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" +) + +type ListPermissionsQuery struct { + Cursor *string + Limit int +} + +type ListPermissionsResult struct { + Permissions []*entity.Permission + NextCursor *string + PrevCursor *string + HasNext bool + HasPrev bool +} + +type ListPermissionsHandler struct { + permRepo repository.PermissionRepository +} + +func NewListPermissionsHandler(permRepo repository.PermissionRepository) *ListPermissionsHandler { + return &ListPermissionsHandler{permRepo: permRepo} +} + +func (h *ListPermissionsHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(ListPermissionsQuery) + permissions, nextCursor, prevCursor, hasNext, hasPrev, err := h.permRepo.GetAll(ctx, q.Cursor, q.Limit) + if err != nil { + return nil, err + } + return ListPermissionsResult{ + Permissions: permissions, + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + }, nil +} diff --git a/internal/authorization/application/query/list_roles.go b/internal/authorization/application/query/list_roles.go new file mode 100644 index 0000000..cc4bdf1 --- /dev/null +++ b/internal/authorization/application/query/list_roles.go @@ -0,0 +1,44 @@ +package query + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" +) + +type ListRolesQuery struct { + Cursor *string + Limit int +} + +type ListRolesResult struct { + Roles []*entity.Role + NextCursor *string + PrevCursor *string + HasNext bool + HasPrev bool +} + +type ListRolesHandler struct { + roleRepo repository.RoleRepository +} + +func NewListRolesHandler(roleRepo repository.RoleRepository) *ListRolesHandler { + return &ListRolesHandler{roleRepo: roleRepo} +} + +func (h *ListRolesHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(ListRolesQuery) + roles, nextCursor, prevCursor, hasNext, hasPrev, err := h.roleRepo.GetAll(ctx, q.Cursor, q.Limit) + if err != nil { + return nil, err + } + return ListRolesResult{ + Roles: roles, + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + }, nil +} diff --git a/internal/authorization/application/service/authorization_service.go b/internal/authorization/application/service/authorization_service.go deleted file mode 100644 index efece74..0000000 --- a/internal/authorization/application/service/authorization_service.go +++ /dev/null @@ -1,179 +0,0 @@ -package service - -import ( - "context" - - "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" - "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" - "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/casbin" - coredomain "github.com/IDTS-LAB/go-codebase/internal/core/domain" - "github.com/google/uuid" -) - -type AuthorizationService struct { - roleRepo repository.RoleRepository - permRepo repository.PermissionRepository - userRoleRepo repository.UserRoleRepository - rolePermRepo repository.RolePermissionRepository - enforcer *casbin.Enforcer -} - -func NewAuthorizationService( - roleRepo repository.RoleRepository, - permRepo repository.PermissionRepository, - userRoleRepo repository.UserRoleRepository, - rolePermRepo repository.RolePermissionRepository, - enforcer *casbin.Enforcer, -) *AuthorizationService { - return &AuthorizationService{ - roleRepo: roleRepo, - permRepo: permRepo, - userRoleRepo: userRoleRepo, - rolePermRepo: rolePermRepo, - enforcer: enforcer, - } -} - -func (s *AuthorizationService) CreateRole(ctx context.Context, name, description string) (*entity.Role, error) { - existing, _ := s.roleRepo.GetByName(ctx, name) - if existing != nil { - return nil, coredomain.ErrConflict - } - role := entity.NewRole(name, description) - if err := s.roleRepo.Create(ctx, role); err != nil { - return nil, err - } - return role, nil -} - -func (s *AuthorizationService) GetRole(ctx context.Context, id uuid.UUID) (*entity.Role, error) { - return s.roleRepo.GetByID(ctx, id) -} - -func (s *AuthorizationService) ListRoles(ctx context.Context, page, perPage int) ([]*entity.Role, int, error) { - offset := (page - 1) * perPage - return s.roleRepo.GetAll(ctx, offset, perPage) -} - -func (s *AuthorizationService) UpdateRole(ctx context.Context, id uuid.UUID, name, description string) (*entity.Role, error) { - role, err := s.roleRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - if name != "" { - role.Name = name - } - if description != "" { - role.Description = description - } - role.Touch() - if err := s.roleRepo.Update(ctx, role); err != nil { - return nil, err - } - return role, nil -} - -func (s *AuthorizationService) DeleteRole(ctx context.Context, id uuid.UUID) error { - return s.roleRepo.Delete(ctx, id) -} - -func (s *AuthorizationService) CreatePermission(ctx context.Context, name, description, resource, action string) (*entity.Permission, error) { - existing, _ := s.permRepo.GetByName(ctx, name) - if existing != nil { - return nil, coredomain.ErrConflict - } - perm := entity.NewPermission(name, description, resource, action) - if err := s.permRepo.Create(ctx, perm); err != nil { - return nil, err - } - return perm, nil -} - -func (s *AuthorizationService) GetPermission(ctx context.Context, id uuid.UUID) (*entity.Permission, error) { - return s.permRepo.GetByID(ctx, id) -} - -func (s *AuthorizationService) ListPermissions(ctx context.Context, page, perPage int) ([]*entity.Permission, int, error) { - offset := (page - 1) * perPage - return s.permRepo.GetAll(ctx, offset, perPage) -} - -func (s *AuthorizationService) UpdatePermission(ctx context.Context, id uuid.UUID, name, description, resource, action string) (*entity.Permission, error) { - perm, err := s.permRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - if name != "" { - perm.Name = name - } - if description != "" { - perm.Description = description - } - if resource != "" { - perm.Resource = resource - } - if action != "" { - perm.Action = action - } - perm.Touch() - if err := s.permRepo.Update(ctx, perm); err != nil { - return nil, err - } - return perm, nil -} - -func (s *AuthorizationService) DeletePermission(ctx context.Context, id uuid.UUID) error { - return s.permRepo.Delete(ctx, id) -} - -func (s *AuthorizationService) AssignRoleToUser(ctx context.Context, userID, roleID uuid.UUID) error { - _, err := s.roleRepo.GetByID(ctx, roleID) - if err != nil { - return err - } - if err := s.userRoleRepo.Assign(ctx, entity.NewUserRole(userID, roleID)); err != nil { - return err - } - return s.enforcer.ReloadUserPolicies(ctx, userID) -} - -func (s *AuthorizationService) RemoveRoleFromUser(ctx context.Context, userID, roleID uuid.UUID) error { - if err := s.userRoleRepo.Remove(ctx, userID, roleID); err != nil { - return err - } - return s.enforcer.ReloadUserPolicies(ctx, userID) -} - -func (s *AuthorizationService) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]*entity.Role, error) { - return s.userRoleRepo.GetRolesByUserID(ctx, userID) -} - -func (s *AuthorizationService) AssignPermissionToRole(ctx context.Context, roleID, permissionID uuid.UUID) error { - _, err := s.roleRepo.GetByID(ctx, roleID) - if err != nil { - return err - } - _, err = s.permRepo.GetByID(ctx, permissionID) - if err != nil { - return err - } - if err := s.rolePermRepo.Assign(ctx, entity.NewRolePermission(roleID, permissionID)); err != nil { - return err - } - return s.enforcer.ReloadPolicies(ctx) -} - -func (s *AuthorizationService) RemovePermissionFromRole(ctx context.Context, roleID, permissionID uuid.UUID) error { - if err := s.rolePermRepo.Remove(ctx, roleID, permissionID); err != nil { - return err - } - return s.enforcer.ReloadPolicies(ctx) -} - -func (s *AuthorizationService) GetRolePermissions(ctx context.Context, roleID uuid.UUID) ([]*entity.Permission, error) { - return s.rolePermRepo.GetPermissionsByRoleID(ctx, roleID) -} - -func (s *AuthorizationService) CheckPermission(ctx context.Context, userID uuid.UUID, resource, action string) (bool, error) { - return s.enforcer.Enforce(userID, resource, action) -} diff --git a/internal/authorization/domain/repository/authorization_repository.go b/internal/authorization/domain/repository/authorization_repository.go index 894a770..f295e53 100644 --- a/internal/authorization/domain/repository/authorization_repository.go +++ b/internal/authorization/domain/repository/authorization_repository.go @@ -11,7 +11,7 @@ type RoleRepository interface { Create(ctx context.Context, role *entity.Role) error GetByID(ctx context.Context, id uuid.UUID) (*entity.Role, error) GetByName(ctx context.Context, name string) (*entity.Role, error) - GetAll(ctx context.Context, offset, limit int) ([]*entity.Role, int, error) + GetAll(ctx context.Context, cursor *string, limit int) ([]*entity.Role, *string, *string, bool, bool, error) Update(ctx context.Context, role *entity.Role) error Delete(ctx context.Context, id uuid.UUID) error } @@ -20,7 +20,7 @@ type PermissionRepository interface { Create(ctx context.Context, perm *entity.Permission) error GetByID(ctx context.Context, id uuid.UUID) (*entity.Permission, error) GetByName(ctx context.Context, name string) (*entity.Permission, error) - GetAll(ctx context.Context, offset, limit int) ([]*entity.Permission, int, error) + GetAll(ctx context.Context, cursor *string, limit int) ([]*entity.Permission, *string, *string, bool, bool, error) Update(ctx context.Context, perm *entity.Permission) error Delete(ctx context.Context, id uuid.UUID) error } diff --git a/internal/authorization/infrastructure/casbin/adapter.go b/internal/authorization/infrastructure/casbin/adapter.go index 7806b95..89f6f94 100644 --- a/internal/authorization/infrastructure/casbin/adapter.go +++ b/internal/authorization/infrastructure/casbin/adapter.go @@ -1,81 +1,148 @@ package casbin import ( - "context" "database/sql" "fmt" + "strings" - "github.com/google/uuid" + "github.com/casbin/casbin/v2/model" + "github.com/casbin/casbin/v2/persist" ) -type Policy struct { - Subject string - Object string - Action string -} - -type PolicyLoader struct { +type Adapter struct { db *sql.DB } -func NewPolicyLoader(db *sql.DB) *PolicyLoader { - return &PolicyLoader{db: db} +func NewAdapter(db *sql.DB) *Adapter { + return &Adapter{db: db} } -func (l *PolicyLoader) LoadAllPolicies(ctx context.Context) ([]Policy, error) { - query := ` - SELECT - ur.user_id::text, - p.resource, - p.action - FROM user_roles ur - JOIN role_permissions rp ON ur.role_id = rp.role_id - JOIN permissions p ON rp.permission_id = p.id - JOIN roles r ON ur.role_id = r.id - WHERE r.deleted_at IS NULL AND p.deleted_at IS NULL` - - rows, err := l.db.QueryContext(ctx, query) +func (a *Adapter) LoadPolicy(model model.Model) error { + rows, err := a.db.Query(`SELECT ptype, v0, v1, v2, v3, v4, v5 FROM casbin_rule`) if err != nil { - return nil, fmt.Errorf("load policies: %w", err) + return fmt.Errorf("load policy: %w", err) } defer rows.Close() - var policies []Policy for rows.Next() { - var pol Policy - if err := rows.Scan(&pol.Subject, &pol.Object, &pol.Action); err != nil { - return nil, fmt.Errorf("scan policy: %w", err) + var ptype string + var v0, v1, v2, v3, v4, v5 string + if err := rows.Scan(&ptype, &v0, &v1, &v2, &v3, &v4, &v5); err != nil { + return fmt.Errorf("scan policy: %w", err) + } + line := ptype + ", " + v0 + ", " + v1 + ", " + v2 + ", " + v3 + ", " + v4 + ", " + v5 + line = strings.TrimRight(line, ", ") + if err := persist.LoadPolicyLine(line, model); err != nil { + return fmt.Errorf("load policy line: %w", err) } - policies = append(policies, pol) } - return policies, nil + return rows.Err() } -func (l *PolicyLoader) LoadUserPolicies(ctx context.Context, userID uuid.UUID) ([]Policy, error) { - query := ` - SELECT - ur.user_id::text, - p.resource, - p.action - FROM user_roles ur - JOIN role_permissions rp ON ur.role_id = rp.role_id - JOIN permissions p ON rp.permission_id = p.id - JOIN roles r ON ur.role_id = r.id - WHERE ur.user_id = $1 AND r.deleted_at IS NULL AND p.deleted_at IS NULL` - - rows, err := l.db.QueryContext(ctx, query, userID) +func (a *Adapter) SavePolicy(model model.Model) error { + tx, err := a.db.Begin() if err != nil { - return nil, fmt.Errorf("load user policies: %w", err) + return fmt.Errorf("save policy begin tx: %w", err) } - defer rows.Close() + defer func() { _ = tx.Rollback() }() - var policies []Policy - for rows.Next() { - var pol Policy - if err := rows.Scan(&pol.Subject, &pol.Object, &pol.Action); err != nil { - return nil, fmt.Errorf("scan policy: %w", err) + if _, err = tx.Exec(`DELETE FROM casbin_rule`); err != nil { + return fmt.Errorf("save policy delete: %w", err) + } + + stmt, err := tx.Prepare(`INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ($1, $2, $3, $4, $5, $6, $7)`) + if err != nil { + return fmt.Errorf("save policy prepare: %w", err) + } + defer stmt.Close() + + for ptype, assertion := range model["p"] { + for _, rule := range assertion.Policy { + values := []interface{}{ptype} + ruleParts := rule + for len(ruleParts) < 6 { + ruleParts = append(ruleParts, "") + } + for _, p := range ruleParts { + values = append(values, p) + } + if _, err = stmt.Exec(values...); err != nil { + return fmt.Errorf("save policy insert: %w", err) + } + } + } + + for ptype, assertion := range model["g"] { + for _, rule := range assertion.Policy { + values := []interface{}{ptype} + ruleParts := rule + for len(ruleParts) < 6 { + ruleParts = append(ruleParts, "") + } + for _, p := range ruleParts { + values = append(values, p) + } + if _, err = stmt.Exec(values...); err != nil { + return fmt.Errorf("save policy insert g: %w", err) + } + } + } + + return tx.Commit() +} + +func (a *Adapter) AddPolicy(sec string, ptype string, rule []string) error { + values := []interface{}{ptype} + for _, v := range rule { + values = append(values, v) + } + for len(values) < 8 { + values = append(values, "") + } + + _, err := a.db.Exec( + `INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + values..., + ) + if err != nil { + return fmt.Errorf("add policy: %w", err) + } + return nil +} + +func (a *Adapter) RemovePolicy(sec string, ptype string, rule []string) error { + query := `DELETE FROM casbin_rule WHERE ptype = $1` + args := []interface{}{ptype} + for i, v := range rule { + query += fmt.Sprintf(" AND v%d = $%d", i, i+2) + args = append(args, v) + } + + _, err := a.db.Exec(query, args...) + if err != nil { + return fmt.Errorf("remove policy: %w", err) + } + return nil +} + +func (a *Adapter) RemoveFilteredPolicy(sec string, ptype string, fieldIndex int, fieldValues ...string) error { + query := `DELETE FROM casbin_rule WHERE ptype = $1` + args := []interface{}{ptype} + + for i, v := range fieldValues { + if v == "" { + continue } - policies = append(policies, pol) + col := fieldIndex + i + query += fmt.Sprintf(" AND v%d = $%d", col, len(args)+1) + args = append(args, v) } - return policies, nil + + _, err := a.db.Exec(query, args...) + if err != nil { + return fmt.Errorf("remove filtered policy: %w", err) + } + return nil } + +var _ persist.Adapter = (*Adapter)(nil) diff --git a/internal/authorization/infrastructure/casbin/enforcer.go b/internal/authorization/infrastructure/casbin/enforcer.go index 202f7d3..b8646ea 100644 --- a/internal/authorization/infrastructure/casbin/enforcer.go +++ b/internal/authorization/infrastructure/casbin/enforcer.go @@ -18,23 +18,23 @@ var Module = fx.Module("casbin", fx.Provide(NewEnforcer)) type Enforcer struct { enforcer *casbin.CachedEnforcer - loader *PolicyLoader + adapter *Adapter } -func NewEnforcer(loader *PolicyLoader) (*Enforcer, error) { +func NewEnforcer(adapter *Adapter) (*Enforcer, error) { m, err := model.NewModelFromString(modelConf) if err != nil { return nil, fmt.Errorf("parse casbin model: %w", err) } - enforcer, err := casbin.NewCachedEnforcer(m) + enforcer, err := casbin.NewCachedEnforcer(m, adapter) if err != nil { return nil, fmt.Errorf("create casbin enforcer: %w", err) } e := &Enforcer{ enforcer: enforcer, - loader: loader, + adapter: adapter, } if err := e.ReloadPolicies(context.Background()); err != nil { @@ -45,32 +45,15 @@ func NewEnforcer(loader *PolicyLoader) (*Enforcer, error) { } func (e *Enforcer) ReloadPolicies(ctx context.Context) error { - policies, err := e.loader.LoadAllPolicies(ctx) - if err != nil { - return err - } - - e.enforcer.ClearPolicy() - for _, p := range policies { - if _, err := e.enforcer.AddPolicy(p.Subject, p.Object, p.Action); err != nil { - return fmt.Errorf("add policy: %w", err) - } + if err := SyncAllPolicies(ctx, e.adapter.db, e.enforcer); err != nil { + return fmt.Errorf("reload policies: %w", err) } return nil } func (e *Enforcer) ReloadUserPolicies(ctx context.Context, userID uuid.UUID) error { - policies, err := e.loader.LoadUserPolicies(ctx, userID) - if err != nil { - return err - } - - subject := userID.String() - e.enforcer.RemoveFilteredPolicy(0, subject) - for _, p := range policies { - if _, err := e.enforcer.AddPolicy(p.Subject, p.Object, p.Action); err != nil { - return fmt.Errorf("add user policy: %w", err) - } + if err := SyncUserPolicies(ctx, e.adapter.db, e.enforcer, userID); err != nil { + return fmt.Errorf("reload user policies: %w", err) } return nil } diff --git a/internal/authorization/infrastructure/casbin/sync.go b/internal/authorization/infrastructure/casbin/sync.go new file mode 100644 index 0000000..5eecf2b --- /dev/null +++ b/internal/authorization/infrastructure/casbin/sync.go @@ -0,0 +1,142 @@ +package casbin + +import ( + "context" + "database/sql" + "fmt" + + "github.com/casbin/casbin/v2" + "github.com/google/uuid" +) + +type flattenedPolicy []string + +func SyncUserPolicies(ctx context.Context, db *sql.DB, enforcer *casbin.CachedEnforcer, userID uuid.UUID) error { + policies, err := loadUserFlattenedPolicies(ctx, db, userID) + if err != nil { + return fmt.Errorf("load flattened policies: %w", err) + } + + subject := userID.String() + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err = tx.ExecContext(ctx, `DELETE FROM casbin_rule WHERE ptype = 'p' AND v0 = $1`, subject); err != nil { + return fmt.Errorf("delete existing policies: %w", err) + } + + stmt, err := tx.PrepareContext(ctx, `INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES ('p', $1, $2, $3)`) + if err != nil { + return fmt.Errorf("prepare insert: %w", err) + } + defer stmt.Close() + + for _, p := range policies { + if _, err = stmt.ExecContext(ctx, p[0], p[1], p[2]); err != nil { + return fmt.Errorf("insert policy: %w", err) + } + } + + if err = tx.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } + + if err = enforcer.LoadPolicy(); err != nil { + return fmt.Errorf("load policy: %w", err) + } + + return nil +} + +func SyncAllPolicies(ctx context.Context, db *sql.DB, enforcer *casbin.CachedEnforcer) error { + policies, err := loadAllFlattenedPolicies(ctx, db) + if err != nil { + return fmt.Errorf("load all flattened policies: %w", err) + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err = tx.ExecContext(ctx, `DELETE FROM casbin_rule WHERE ptype = 'p'`); err != nil { + return fmt.Errorf("delete existing policies: %w", err) + } + + stmt, err := tx.PrepareContext(ctx, `INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES ('p', $1, $2, $3)`) + if err != nil { + return fmt.Errorf("prepare insert: %w", err) + } + defer stmt.Close() + + for _, p := range policies { + if _, err = stmt.ExecContext(ctx, p[0], p[1], p[2]); err != nil { + return fmt.Errorf("insert policy: %w", err) + } + } + + if err = tx.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } + + return enforcer.LoadPolicy() +} + +func loadUserFlattenedPolicies(ctx context.Context, db *sql.DB, userID uuid.UUID) ([]flattenedPolicy, error) { + query := ` + SELECT ur.user_id::text, p.resource, p.action + FROM user_roles ur + JOIN role_permissions rp ON ur.role_id = rp.role_id + JOIN permissions p ON rp.permission_id = p.id + JOIN roles r ON ur.role_id = r.id + WHERE ur.user_id = $1 AND r.deleted_at IS NULL AND p.deleted_at IS NULL + GROUP BY ur.user_id, p.resource, p.action` + + rows, err := db.QueryContext(ctx, query, userID) + if err != nil { + return nil, fmt.Errorf("query user policies: %w", err) + } + defer rows.Close() + + var policies []flattenedPolicy + for rows.Next() { + var sub, obj, act string + if err = rows.Scan(&sub, &obj, &act); err != nil { + return nil, fmt.Errorf("scan: %w", err) + } + policies = append(policies, flattenedPolicy{sub, obj, act}) + } + return policies, rows.Err() +} + +func loadAllFlattenedPolicies(ctx context.Context, db *sql.DB) ([]flattenedPolicy, error) { + query := ` + SELECT ur.user_id::text, p.resource, p.action + FROM user_roles ur + JOIN role_permissions rp ON ur.role_id = rp.role_id + JOIN permissions p ON rp.permission_id = p.id + JOIN roles r ON ur.role_id = r.id + WHERE r.deleted_at IS NULL AND p.deleted_at IS NULL + GROUP BY ur.user_id, p.resource, p.action` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("query all policies: %w", err) + } + defer rows.Close() + + var policies []flattenedPolicy + for rows.Next() { + var sub, obj, act string + if err = rows.Scan(&sub, &obj, &act); err != nil { + return nil, fmt.Errorf("scan: %w", err) + } + policies = append(policies, flattenedPolicy{sub, obj, act}) + } + return policies, rows.Err() +} diff --git a/internal/authorization/infrastructure/persistence/permission_repository.go b/internal/authorization/infrastructure/persistence/permission_repository.go index 6f37629..2fbc05f 100644 --- a/internal/authorization/infrastructure/persistence/permission_repository.go +++ b/internal/authorization/infrastructure/persistence/permission_repository.go @@ -4,23 +4,38 @@ import ( "context" "database/sql" "fmt" + "time" "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/persistence/sqlc" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/cursor" + "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" + "github.com/IDTS-LAB/go-codebase/internal/shared/tenantfilter" "github.com/google/uuid" ) type permissionRepository struct { - db *sql.DB + db *sql.DB + tenantConfig *tenantfilter.Config } -func NewPermissionRepository(db *sql.DB) repository.PermissionRepository { - return &permissionRepository{db: db} +func NewPermissionRepository(db *sql.DB, tenantConfig *tenantfilter.Config) repository.PermissionRepository { + return &permissionRepository{db: db, tenantConfig: tenantConfig} } func (r *permissionRepository) Create(ctx context.Context, perm *entity.Permission) error { - query := `INSERT INTO permissions (id, name, description, resource, action, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)` - _, err := r.db.ExecContext(ctx, query, perm.ID, perm.Name, perm.Description, perm.Resource, perm.Action, perm.CreatedAt, perm.UpdatedAt) + q := sqlc.New(r.db) + err := q.CreatePermission(ctx, sqlc.CreatePermissionParams{ + ID: perm.ID, + Name: perm.Name, + Description: perm.Description, + Resource: perm.Resource, + Action: perm.Action, + CreatedAt: perm.CreatedAt, + UpdatedAt: perm.UpdatedAt, + }) if err != nil { return fmt.Errorf("insert permission: %w", err) } @@ -28,62 +43,115 @@ func (r *permissionRepository) Create(ctx context.Context, perm *entity.Permissi } func (r *permissionRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.Permission, error) { - query := `SELECT id, name, description, resource, action, created_at, updated_at, deleted_at FROM permissions WHERE id = $1 AND deleted_at IS NULL` - perm := &entity.Permission{} - err := r.db.QueryRowContext(ctx, query, id).Scan(&perm.ID, &perm.Name, &perm.Description, &perm.Resource, &perm.Action, &perm.CreatedAt, &perm.UpdatedAt, &perm.DeletedAt) + q := sqlc.New(r.db) + row, err := q.GetPermissionByID(ctx, id) if err == sql.ErrNoRows { return nil, fmt.Errorf("permission not found") } if err != nil { return nil, fmt.Errorf("get permission: %w", err) } - return perm, nil + return mapPermissionRowToEntity(row.ID, row.Name, row.Description, row.Resource, row.Action, row.CreatedAt, row.UpdatedAt, row.DeletedAt), nil } func (r *permissionRepository) GetByName(ctx context.Context, name string) (*entity.Permission, error) { - query := `SELECT id, name, description, resource, action, created_at, updated_at, deleted_at FROM permissions WHERE name = $1 AND deleted_at IS NULL` - perm := &entity.Permission{} - err := r.db.QueryRowContext(ctx, query, name).Scan(&perm.ID, &perm.Name, &perm.Description, &perm.Resource, &perm.Action, &perm.CreatedAt, &perm.UpdatedAt, &perm.DeletedAt) + q := sqlc.New(r.db) + row, err := q.GetPermissionByName(ctx, name) if err == sql.ErrNoRows { return nil, fmt.Errorf("permission not found") } if err != nil { return nil, fmt.Errorf("get permission by name: %w", err) } - return perm, nil + return mapPermissionRowToEntity(row.ID, row.Name, row.Description, row.Resource, row.Action, row.CreatedAt, row.UpdatedAt, row.DeletedAt), nil } -func (r *permissionRepository) GetAll(ctx context.Context, offset, limit int) ([]*entity.Permission, int, error) { - var total int - if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM permissions WHERE deleted_at IS NULL`).Scan(&total); err != nil { - return nil, 0, fmt.Errorf("count permissions: %w", err) +func (r *permissionRepository) GetAll(ctx context.Context, cursorArg *string, limit int) ([]*entity.Permission, *string, *string, bool, bool, error) { + args := []interface{}{} + whereClause := "WHERE deleted_at IS NULL" + + if r.tenantConfig != nil && r.tenantConfig.Enabled { + tenantID := middleware.GetTenantID(ctx) + if tenantID != "" { + whereClause += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tenantID) + } + } + + nextPos := len(args) + 1 + if cursorArg != nil { + c, err := cursor.Decode(*cursorArg) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("invalid cursor: %w", err) + } + whereClause += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", nextPos, nextPos+1) + args = append(args, c.Timestamp, c.ID) + nextPos += 2 } - query := `SELECT id, name, description, resource, action, created_at, updated_at, deleted_at FROM permissions WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2` - rows, err := r.db.QueryContext(ctx, query, limit, offset) + dataQuery := fmt.Sprintf("SELECT id, name, description, resource, action, created_at, updated_at, deleted_at FROM permissions %s ORDER BY created_at DESC, id DESC LIMIT $%d", whereClause, nextPos) + args = append(args, limit+1) + + rows, err := r.db.QueryContext(ctx, dataQuery, args...) if err != nil { - return nil, 0, fmt.Errorf("query permissions: %w", err) + return nil, nil, nil, false, false, fmt.Errorf("query permissions: %w", err) } defer rows.Close() var perms []*entity.Permission for rows.Next() { - perm := &entity.Permission{} - if err := rows.Scan(&perm.ID, &perm.Name, &perm.Description, &perm.Resource, &perm.Action, &perm.CreatedAt, &perm.UpdatedAt, &perm.DeletedAt); err != nil { - return nil, 0, fmt.Errorf("scan permission: %w", err) + var p entity.Permission + var deletedAt sql.NullTime + if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Resource, &p.Action, &p.CreatedAt, &p.UpdatedAt, &deletedAt); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("scan permission: %w", err) + } + if deletedAt.Valid { + p.DeletedAt = &deletedAt.Time } - perms = append(perms, perm) + perms = append(perms, &p) + } + if err := rows.Err(); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("rows iteration: %w", err) + } + + hasNext := len(perms) > limit + if hasNext { + perms = perms[:limit] } - return perms, total, nil + + var nextCursor *string + var prevCursor *string + if len(perms) > 0 { + last := perms[len(perms)-1] + nc := cursor.Encode(last.CreatedAt, last.ID) + nextCursor = &nc + + first := perms[0] + pc := cursor.Encode(first.CreatedAt, first.ID) + prevCursor = &pc + } + + hasPrev := cursorArg != nil + if hasPrev && len(perms) == 0 { + hasPrev = false + } + + return perms, nextCursor, prevCursor, hasNext, hasPrev, nil } func (r *permissionRepository) Update(ctx context.Context, perm *entity.Permission) error { - query := `UPDATE permissions SET name = $2, description = $3, resource = $4, action = $5, updated_at = $6 WHERE id = $1 AND deleted_at IS NULL` - result, err := r.db.ExecContext(ctx, query, perm.ID, perm.Name, perm.Description, perm.Resource, perm.Action, perm.UpdatedAt) + q := sqlc.New(r.db) + rows, err := q.UpdatePermission(ctx, sqlc.UpdatePermissionParams{ + ID: perm.ID, + Name: perm.Name, + Description: perm.Description, + Resource: perm.Resource, + Action: perm.Action, + UpdatedAt: perm.UpdatedAt, + }) if err != nil { return fmt.Errorf("update permission: %w", err) } - rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("permission not found") } @@ -91,14 +159,28 @@ func (r *permissionRepository) Update(ctx context.Context, perm *entity.Permissi } func (r *permissionRepository) Delete(ctx context.Context, id uuid.UUID) error { - query := `UPDATE permissions SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL` - result, err := r.db.ExecContext(ctx, query, id) + q := sqlc.New(r.db) + rows, err := q.DeletePermission(ctx, id) if err != nil { return fmt.Errorf("delete permission: %w", err) } - rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("permission not found") } return nil } + +func mapPermissionRowToEntity(id uuid.UUID, name, description, resource, action string, createdAt, updatedAt time.Time, deletedAt sql.NullTime) *entity.Permission { + return &entity.Permission{ + Entity: domain.Entity{ + ID: id, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + DeletedAt: nullTimeToPtr(deletedAt), + }, + Name: name, + Description: description, + Resource: resource, + Action: action, + } +} diff --git a/internal/authorization/infrastructure/persistence/queries/queries.sql b/internal/authorization/infrastructure/persistence/queries/queries.sql new file mode 100644 index 0000000..a333ed0 --- /dev/null +++ b/internal/authorization/infrastructure/persistence/queries/queries.sql @@ -0,0 +1,67 @@ +-- name: CreateRole :exec +INSERT INTO roles (id, name, description, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5); + +-- name: GetRoleByID :one +SELECT id, name, description, created_at, updated_at, deleted_at +FROM roles WHERE id = $1 AND deleted_at IS NULL; + +-- name: GetRoleByName :one +SELECT id, name, description, created_at, updated_at, deleted_at +FROM roles WHERE name = $1 AND deleted_at IS NULL; + +-- name: UpdateRole :execrows +UPDATE roles SET name = $2, description = $3, updated_at = $4 WHERE id = $1 AND deleted_at IS NULL; + +-- name: DeleteRole :execrows +UPDATE roles SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL; + +-- name: CreatePermission :exec +INSERT INTO permissions (id, name, description, resource, action, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7); + +-- name: GetPermissionByID :one +SELECT id, name, description, resource, action, created_at, updated_at, deleted_at +FROM permissions WHERE id = $1 AND deleted_at IS NULL; + +-- name: GetPermissionByName :one +SELECT id, name, description, resource, action, created_at, updated_at, deleted_at +FROM permissions WHERE name = $1 AND deleted_at IS NULL; + +-- name: UpdatePermission :execrows +UPDATE permissions SET name = $2, description = $3, resource = $4, action = $5, updated_at = $6 WHERE id = $1 AND deleted_at IS NULL; + +-- name: DeletePermission :execrows +UPDATE permissions SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL; + +-- name: AssignRolePermission :exec +INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2) ON CONFLICT (role_id, permission_id) DO NOTHING; + +-- name: RemoveRolePermission :exec +DELETE FROM role_permissions WHERE role_id = $1 AND permission_id = $2; + +-- name: GetRolePermissionsByRoleID :many +SELECT role_id, permission_id FROM role_permissions WHERE role_id = $1; + +-- name: GetPermissionsByRoleID :many +SELECT p.id, p.name, p.description, p.resource, p.action, p.created_at, p.updated_at, p.deleted_at +FROM permissions p +JOIN role_permissions rp ON p.id = rp.permission_id +WHERE rp.role_id = $1 AND p.deleted_at IS NULL +ORDER BY p.created_at DESC; + +-- name: AssignUserRole :exec +INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT (user_id, role_id) DO NOTHING; + +-- name: RemoveUserRole :exec +DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2; + +-- name: GetUserRolesByUserID :many +SELECT user_id, role_id FROM user_roles WHERE user_id = $1; + +-- name: GetRolesByUserID :many +SELECT r.id, r.name, r.description, r.created_at, r.updated_at, r.deleted_at +FROM roles r +JOIN user_roles ur ON r.id = ur.role_id +WHERE ur.user_id = $1 AND r.deleted_at IS NULL +ORDER BY r.created_at DESC; diff --git a/internal/authorization/infrastructure/persistence/role_permission_repository.go b/internal/authorization/infrastructure/persistence/role_permission_repository.go index fa04beb..53f6d15 100644 --- a/internal/authorization/infrastructure/persistence/role_permission_repository.go +++ b/internal/authorization/infrastructure/persistence/role_permission_repository.go @@ -7,6 +7,7 @@ import ( "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/persistence/sqlc" "github.com/google/uuid" ) @@ -19,8 +20,11 @@ func NewRolePermissionRepository(db *sql.DB) repository.RolePermissionRepository } func (r *rolePermissionRepository) Assign(ctx context.Context, rp entity.RolePermission) error { - query := `INSERT INTO role_permissions (role_id, permission_id, created_at) VALUES ($1, $2, NOW()) ON CONFLICT (role_id, permission_id) DO NOTHING` - _, err := r.db.ExecContext(ctx, query, rp.RoleID, rp.PermissionID) + q := sqlc.New(r.db) + err := q.AssignRolePermission(ctx, sqlc.AssignRolePermissionParams{ + RoleID: rp.RoleID, + PermissionID: rp.PermissionID, + }) if err != nil { return fmt.Errorf("assign permission: %w", err) } @@ -28,8 +32,11 @@ func (r *rolePermissionRepository) Assign(ctx context.Context, rp entity.RolePer } func (r *rolePermissionRepository) Remove(ctx context.Context, roleID, permissionID uuid.UUID) error { - query := `DELETE FROM role_permissions WHERE role_id = $1 AND permission_id = $2` - _, err := r.db.ExecContext(ctx, query, roleID, permissionID) + q := sqlc.New(r.db) + err := q.RemoveRolePermission(ctx, sqlc.RemoveRolePermissionParams{ + RoleID: roleID, + PermissionID: permissionID, + }) if err != nil { return fmt.Errorf("remove permission: %w", err) } @@ -37,44 +44,30 @@ func (r *rolePermissionRepository) Remove(ctx context.Context, roleID, permissio } func (r *rolePermissionRepository) GetByRoleID(ctx context.Context, roleID uuid.UUID) ([]entity.RolePermission, error) { - query := `SELECT role_id, permission_id FROM role_permissions WHERE role_id = $1` - rows, err := r.db.QueryContext(ctx, query, roleID) + q := sqlc.New(r.db) + rows, err := q.GetRolePermissionsByRoleID(ctx, roleID) if err != nil { return nil, fmt.Errorf("get role permissions: %w", err) } - defer rows.Close() - - var rps []entity.RolePermission - for rows.Next() { - var rp entity.RolePermission - if err := rows.Scan(&rp.RoleID, &rp.PermissionID); err != nil { - return nil, fmt.Errorf("scan role permission: %w", err) + rps := make([]entity.RolePermission, len(rows)) + for i, row := range rows { + rps[i] = entity.RolePermission{ + RoleID: row.RoleID, + PermissionID: row.PermissionID, } - rps = append(rps, rp) } return rps, nil } func (r *rolePermissionRepository) GetPermissionsByRoleID(ctx context.Context, roleID uuid.UUID) ([]*entity.Permission, error) { - query := ` - SELECT p.id, p.name, p.description, p.resource, p.action, p.created_at, p.updated_at, p.deleted_at - FROM permissions p - JOIN role_permissions rp ON p.id = rp.permission_id - WHERE rp.role_id = $1 AND p.deleted_at IS NULL - ORDER BY p.created_at DESC` - rows, err := r.db.QueryContext(ctx, query, roleID) + q := sqlc.New(r.db) + rows, err := q.GetPermissionsByRoleID(ctx, roleID) if err != nil { return nil, fmt.Errorf("get permissions by role: %w", err) } - defer rows.Close() - - var perms []*entity.Permission - for rows.Next() { - perm := &entity.Permission{} - if err := rows.Scan(&perm.ID, &perm.Name, &perm.Description, &perm.Resource, &perm.Action, &perm.CreatedAt, &perm.UpdatedAt, &perm.DeletedAt); err != nil { - return nil, fmt.Errorf("scan permission: %w", err) - } - perms = append(perms, perm) + perms := make([]*entity.Permission, len(rows)) + for i, row := range rows { + perms[i] = mapPermissionRowToEntity(row.ID, row.Name, row.Description, row.Resource, row.Action, row.CreatedAt, row.UpdatedAt, row.DeletedAt) } return perms, nil } diff --git a/internal/authorization/infrastructure/persistence/role_repository.go b/internal/authorization/infrastructure/persistence/role_repository.go index a194828..54c746d 100644 --- a/internal/authorization/infrastructure/persistence/role_repository.go +++ b/internal/authorization/infrastructure/persistence/role_repository.go @@ -4,23 +4,36 @@ import ( "context" "database/sql" "fmt" + "time" "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/persistence/sqlc" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/cursor" + "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" + "github.com/IDTS-LAB/go-codebase/internal/shared/tenantfilter" "github.com/google/uuid" ) type roleRepository struct { - db *sql.DB + db *sql.DB + tenantConfig *tenantfilter.Config } -func NewRoleRepository(db *sql.DB) repository.RoleRepository { - return &roleRepository{db: db} +func NewRoleRepository(db *sql.DB, tenantConfig *tenantfilter.Config) repository.RoleRepository { + return &roleRepository{db: db, tenantConfig: tenantConfig} } func (r *roleRepository) Create(ctx context.Context, role *entity.Role) error { - query := `INSERT INTO roles (id, name, description, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)` - _, err := r.db.ExecContext(ctx, query, role.ID, role.Name, role.Description, role.CreatedAt, role.UpdatedAt) + q := sqlc.New(r.db) + err := q.CreateRole(ctx, sqlc.CreateRoleParams{ + ID: role.ID, + Name: role.Name, + Description: role.Description, + CreatedAt: role.CreatedAt, + UpdatedAt: role.UpdatedAt, + }) if err != nil { return fmt.Errorf("insert role: %w", err) } @@ -28,62 +41,113 @@ func (r *roleRepository) Create(ctx context.Context, role *entity.Role) error { } func (r *roleRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.Role, error) { - query := `SELECT id, name, description, created_at, updated_at, deleted_at FROM roles WHERE id = $1 AND deleted_at IS NULL` - role := &entity.Role{} - err := r.db.QueryRowContext(ctx, query, id).Scan(&role.ID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt, &role.DeletedAt) + q := sqlc.New(r.db) + row, err := q.GetRoleByID(ctx, id) if err == sql.ErrNoRows { return nil, fmt.Errorf("role not found") } if err != nil { return nil, fmt.Errorf("get role: %w", err) } - return role, nil + return mapRoleRowToEntity(row.ID, row.Name, row.Description, row.CreatedAt, row.UpdatedAt, row.DeletedAt), nil } func (r *roleRepository) GetByName(ctx context.Context, name string) (*entity.Role, error) { - query := `SELECT id, name, description, created_at, updated_at, deleted_at FROM roles WHERE name = $1 AND deleted_at IS NULL` - role := &entity.Role{} - err := r.db.QueryRowContext(ctx, query, name).Scan(&role.ID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt, &role.DeletedAt) + q := sqlc.New(r.db) + row, err := q.GetRoleByName(ctx, name) if err == sql.ErrNoRows { return nil, fmt.Errorf("role not found") } if err != nil { return nil, fmt.Errorf("get role by name: %w", err) } - return role, nil + return mapRoleRowToEntity(row.ID, row.Name, row.Description, row.CreatedAt, row.UpdatedAt, row.DeletedAt), nil } -func (r *roleRepository) GetAll(ctx context.Context, offset, limit int) ([]*entity.Role, int, error) { - var total int - if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM roles WHERE deleted_at IS NULL`).Scan(&total); err != nil { - return nil, 0, fmt.Errorf("count roles: %w", err) +func (r *roleRepository) GetAll(ctx context.Context, cursorArg *string, limit int) ([]*entity.Role, *string, *string, bool, bool, error) { + args := []interface{}{} + whereClause := "WHERE deleted_at IS NULL" + + if r.tenantConfig != nil && r.tenantConfig.Enabled { + tenantID := middleware.GetTenantID(ctx) + if tenantID != "" { + whereClause += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tenantID) + } + } + + nextPos := len(args) + 1 + if cursorArg != nil { + c, err := cursor.Decode(*cursorArg) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("invalid cursor: %w", err) + } + whereClause += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", nextPos, nextPos+1) + args = append(args, c.Timestamp, c.ID) + nextPos += 2 } - query := `SELECT id, name, description, created_at, updated_at, deleted_at FROM roles WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2` - rows, err := r.db.QueryContext(ctx, query, limit, offset) + dataQuery := fmt.Sprintf("SELECT id, name, description, created_at, updated_at, deleted_at FROM roles %s ORDER BY created_at DESC, id DESC LIMIT $%d", whereClause, nextPos) + args = append(args, limit+1) + + rows, err := r.db.QueryContext(ctx, dataQuery, args...) if err != nil { - return nil, 0, fmt.Errorf("query roles: %w", err) + return nil, nil, nil, false, false, fmt.Errorf("query roles: %w", err) } defer rows.Close() var roles []*entity.Role for rows.Next() { - role := &entity.Role{} - if err := rows.Scan(&role.ID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt, &role.DeletedAt); err != nil { - return nil, 0, fmt.Errorf("scan role: %w", err) + var rl entity.Role + var deletedAt sql.NullTime + if err := rows.Scan(&rl.ID, &rl.Name, &rl.Description, &rl.CreatedAt, &rl.UpdatedAt, &deletedAt); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("scan role: %w", err) } - roles = append(roles, role) + if deletedAt.Valid { + rl.DeletedAt = &deletedAt.Time + } + roles = append(roles, &rl) + } + if err := rows.Err(); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("rows iteration: %w", err) + } + + hasNext := len(roles) > limit + if hasNext { + roles = roles[:limit] } - return roles, total, nil + + var nextCursor *string + var prevCursor *string + if len(roles) > 0 { + last := roles[len(roles)-1] + nc := cursor.Encode(last.CreatedAt, last.ID) + nextCursor = &nc + + first := roles[0] + pc := cursor.Encode(first.CreatedAt, first.ID) + prevCursor = &pc + } + + hasPrev := cursorArg != nil + if hasPrev && len(roles) == 0 { + hasPrev = false + } + + return roles, nextCursor, prevCursor, hasNext, hasPrev, nil } func (r *roleRepository) Update(ctx context.Context, role *entity.Role) error { - query := `UPDATE roles SET name = $2, description = $3, updated_at = $4 WHERE id = $1 AND deleted_at IS NULL` - result, err := r.db.ExecContext(ctx, query, role.ID, role.Name, role.Description, role.UpdatedAt) + q := sqlc.New(r.db) + rows, err := q.UpdateRole(ctx, sqlc.UpdateRoleParams{ + ID: role.ID, + Name: role.Name, + Description: role.Description, + UpdatedAt: role.UpdatedAt, + }) if err != nil { return fmt.Errorf("update role: %w", err) } - rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("role not found") } @@ -91,14 +155,33 @@ func (r *roleRepository) Update(ctx context.Context, role *entity.Role) error { } func (r *roleRepository) Delete(ctx context.Context, id uuid.UUID) error { - query := `UPDATE roles SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL` - result, err := r.db.ExecContext(ctx, query, id) + q := sqlc.New(r.db) + rows, err := q.DeleteRole(ctx, id) if err != nil { return fmt.Errorf("delete role: %w", err) } - rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("role not found") } return nil } + +func mapRoleRowToEntity(id uuid.UUID, name, description string, createdAt, updatedAt time.Time, deletedAt sql.NullTime) *entity.Role { + return &entity.Role{ + Entity: domain.Entity{ + ID: id, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + DeletedAt: nullTimeToPtr(deletedAt), + }, + Name: name, + Description: description, + } +} + +func nullTimeToPtr(nt sql.NullTime) *time.Time { + if nt.Valid { + return &nt.Time + } + return nil +} diff --git a/internal/authorization/infrastructure/persistence/sqlc/db.go b/internal/authorization/infrastructure/persistence/sqlc/db.go new file mode 100644 index 0000000..5922bc9 --- /dev/null +++ b/internal/authorization/infrastructure/persistence/sqlc/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/authorization/infrastructure/persistence/sqlc/models.go b/internal/authorization/infrastructure/persistence/sqlc/models.go new file mode 100644 index 0000000..4a7067e --- /dev/null +++ b/internal/authorization/infrastructure/persistence/sqlc/models.go @@ -0,0 +1,225 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "database/sql" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type AuditLog struct { + ID uuid.UUID `json:"id"` + RequestID string `json:"request_id"` + UserID uuid.NullUUID `json:"user_id"` + UserEmail sql.NullString `json:"user_email"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int32 `json:"status_code"` + DurationMs int64 `json:"duration_ms"` + Ip string `json:"ip"` + UserAgent string `json:"user_agent"` + RequestBody sql.NullString `json:"request_body"` + ResponseSize int32 `json:"response_size"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type CasbinRule struct { + ID int32 `json:"id"` + Ptype string `json:"ptype"` + V0 sql.NullString `json:"v0"` + V1 sql.NullString `json:"v1"` + V2 sql.NullString `json:"v2"` + V3 sql.NullString `json:"v3"` + V4 sql.NullString `json:"v4"` + V5 sql.NullString `json:"v5"` +} + +type ErrorLog struct { + ID uuid.UUID `json:"id"` + RequestID string `json:"request_id"` + UserID uuid.NullUUID `json:"user_id"` + UserEmail sql.NullString `json:"user_email"` + Level string `json:"level"` + Message string `json:"message"` + Error string `json:"error"` + StackTrace string `json:"stack_trace"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int32 `json:"status_code"` + Ip string `json:"ip"` + UserAgent string `json:"user_agent"` + RequestBody sql.NullString `json:"request_body"` + Metadata []byte `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type Permission struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Resource string `json:"resource"` + Action string `json:"action"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + TenantID string `json:"tenant_id"` +} + +type RefreshToken struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + RevokedAt sql.NullTime `json:"revoked_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +type Role struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + TenantID string `json:"tenant_id"` +} + +type RolePermission struct { + RoleID uuid.UUID `json:"role_id"` + PermissionID uuid.UUID `json:"permission_id"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type Tenant struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Domain sql.NullString `json:"domain"` + Settings json.RawMessage `json:"settings"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Todo struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Completed bool `json:"completed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + TenantID string `json:"tenant_id"` +} + +type User struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + FailedLoginAttempts int32 `json:"failed_login_attempts"` + EmailVerified sql.NullBool `json:"email_verified"` + EmailVerifyToken sql.NullString `json:"email_verify_token"` + EmailVerifyExpires sql.NullTime `json:"email_verify_expires"` + PasswordResetToken sql.NullString `json:"password_reset_token"` + PasswordResetExpires sql.NullTime `json:"password_reset_expires"` + TenantID string `json:"tenant_id"` + EmailVerifiedAt sql.NullTime `json:"email_verified_at"` +} + +type UserAddress struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Label string `json:"label"` + IsDefault bool `json:"is_default"` + Street string `json:"street"` + City string `json:"city"` + State string `json:"state"` + PostalCode string `json:"postal_code"` + Country string `json:"country"` +} + +type UserCredential struct { + UserID uuid.UUID `json:"user_id"` + PasswordHash string `json:"password_hash"` + LastLoginAt sql.NullTime `json:"last_login_at"` +} + +type UserPreference struct { + UserID uuid.UUID `json:"user_id"` + Preferences json.RawMessage `json:"preferences"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserProfile struct { + UserID uuid.UUID `json:"user_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Phone sql.NullString `json:"phone"` + AvatarUrl sql.NullString `json:"avatar_url"` + Timezone string `json:"timezone"` + Locale string `json:"locale"` + Bio sql.NullString `json:"bio"` +} + +type UserRole struct { + UserID uuid.UUID `json:"user_id"` + RoleID uuid.UUID `json:"role_id"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type UserSecurity struct { + UserID uuid.UUID `json:"user_id"` + LoginAttempts int32 `json:"login_attempts"` + LockedUntil sql.NullTime `json:"locked_until"` + MfaEnabled bool `json:"mfa_enabled"` + MfaSecret sql.NullString `json:"mfa_secret"` +} + +type UserSession struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + RefreshTokenHash sql.NullString `json:"refresh_token_hash"` + DeviceInfo string `json:"device_info"` + IpAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + ExpiresAt sql.NullTime `json:"expires_at"` + LastUsedAt time.Time `json:"last_used_at"` + RevokedAt sql.NullTime `json:"revoked_at"` + CreatedAt time.Time `json:"created_at"` +} + +type UserSocialLink struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Provider string `json:"provider"` + ProviderID string `json:"provider_id"` + ProviderEmail sql.NullString `json:"provider_email"` + AvatarUrl sql.NullString `json:"avatar_url"` + CreatedAt time.Time `json:"created_at"` +} + +type UserToken struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + TokenType string `json:"token_type"` + TokenHash sql.NullString `json:"token_hash"` + ExpiresAt sql.NullTime `json:"expires_at"` + ConsumedAt sql.NullTime `json:"consumed_at"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/authorization/infrastructure/persistence/sqlc/queries.sql.go b/internal/authorization/infrastructure/persistence/sqlc/queries.sql.go new file mode 100644 index 0000000..ed1ddfc --- /dev/null +++ b/internal/authorization/infrastructure/persistence/sqlc/queries.sql.go @@ -0,0 +1,480 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: queries.sql + +package sqlc + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" +) + +const assignRolePermission = `-- name: AssignRolePermission :exec +INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2) ON CONFLICT (role_id, permission_id) DO NOTHING +` + +type AssignRolePermissionParams struct { + RoleID uuid.UUID `json:"role_id"` + PermissionID uuid.UUID `json:"permission_id"` +} + +func (q *Queries) AssignRolePermission(ctx context.Context, arg AssignRolePermissionParams) error { + _, err := q.db.ExecContext(ctx, assignRolePermission, arg.RoleID, arg.PermissionID) + return err +} + +const assignUserRole = `-- name: AssignUserRole :exec +INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT (user_id, role_id) DO NOTHING +` + +type AssignUserRoleParams struct { + UserID uuid.UUID `json:"user_id"` + RoleID uuid.UUID `json:"role_id"` +} + +func (q *Queries) AssignUserRole(ctx context.Context, arg AssignUserRoleParams) error { + _, err := q.db.ExecContext(ctx, assignUserRole, arg.UserID, arg.RoleID) + return err +} + +const createPermission = `-- name: CreatePermission :exec +INSERT INTO permissions (id, name, description, resource, action, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7) +` + +type CreatePermissionParams struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Resource string `json:"resource"` + Action string `json:"action"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (q *Queries) CreatePermission(ctx context.Context, arg CreatePermissionParams) error { + _, err := q.db.ExecContext(ctx, createPermission, + arg.ID, + arg.Name, + arg.Description, + arg.Resource, + arg.Action, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const createRole = `-- name: CreateRole :exec +INSERT INTO roles (id, name, description, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5) +` + +type CreateRoleParams struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (q *Queries) CreateRole(ctx context.Context, arg CreateRoleParams) error { + _, err := q.db.ExecContext(ctx, createRole, + arg.ID, + arg.Name, + arg.Description, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const deletePermission = `-- name: DeletePermission :execrows +UPDATE permissions SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL +` + +func (q *Queries) DeletePermission(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.ExecContext(ctx, deletePermission, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deleteRole = `-- name: DeleteRole :execrows +UPDATE roles SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL +` + +func (q *Queries) DeleteRole(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteRole, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getPermissionByID = `-- name: GetPermissionByID :one +SELECT id, name, description, resource, action, created_at, updated_at, deleted_at +FROM permissions WHERE id = $1 AND deleted_at IS NULL +` + +type GetPermissionByIDRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Resource string `json:"resource"` + Action string `json:"action"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +func (q *Queries) GetPermissionByID(ctx context.Context, id uuid.UUID) (GetPermissionByIDRow, error) { + row := q.db.QueryRowContext(ctx, getPermissionByID, id) + var i GetPermissionByIDRow + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Resource, + &i.Action, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const getPermissionByName = `-- name: GetPermissionByName :one +SELECT id, name, description, resource, action, created_at, updated_at, deleted_at +FROM permissions WHERE name = $1 AND deleted_at IS NULL +` + +type GetPermissionByNameRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Resource string `json:"resource"` + Action string `json:"action"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +func (q *Queries) GetPermissionByName(ctx context.Context, name string) (GetPermissionByNameRow, error) { + row := q.db.QueryRowContext(ctx, getPermissionByName, name) + var i GetPermissionByNameRow + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Resource, + &i.Action, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const getPermissionsByRoleID = `-- name: GetPermissionsByRoleID :many +SELECT p.id, p.name, p.description, p.resource, p.action, p.created_at, p.updated_at, p.deleted_at +FROM permissions p +JOIN role_permissions rp ON p.id = rp.permission_id +WHERE rp.role_id = $1 AND p.deleted_at IS NULL +ORDER BY p.created_at DESC +` + +type GetPermissionsByRoleIDRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Resource string `json:"resource"` + Action string `json:"action"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +func (q *Queries) GetPermissionsByRoleID(ctx context.Context, roleID uuid.UUID) ([]GetPermissionsByRoleIDRow, error) { + rows, err := q.db.QueryContext(ctx, getPermissionsByRoleID, roleID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetPermissionsByRoleIDRow{} + for rows.Next() { + var i GetPermissionsByRoleIDRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Resource, + &i.Action, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getRoleByID = `-- name: GetRoleByID :one +SELECT id, name, description, created_at, updated_at, deleted_at +FROM roles WHERE id = $1 AND deleted_at IS NULL +` + +type GetRoleByIDRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +func (q *Queries) GetRoleByID(ctx context.Context, id uuid.UUID) (GetRoleByIDRow, error) { + row := q.db.QueryRowContext(ctx, getRoleByID, id) + var i GetRoleByIDRow + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const getRoleByName = `-- name: GetRoleByName :one +SELECT id, name, description, created_at, updated_at, deleted_at +FROM roles WHERE name = $1 AND deleted_at IS NULL +` + +type GetRoleByNameRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +func (q *Queries) GetRoleByName(ctx context.Context, name string) (GetRoleByNameRow, error) { + row := q.db.QueryRowContext(ctx, getRoleByName, name) + var i GetRoleByNameRow + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const getRolePermissionsByRoleID = `-- name: GetRolePermissionsByRoleID :many +SELECT role_id, permission_id FROM role_permissions WHERE role_id = $1 +` + +type GetRolePermissionsByRoleIDRow struct { + RoleID uuid.UUID `json:"role_id"` + PermissionID uuid.UUID `json:"permission_id"` +} + +func (q *Queries) GetRolePermissionsByRoleID(ctx context.Context, roleID uuid.UUID) ([]GetRolePermissionsByRoleIDRow, error) { + rows, err := q.db.QueryContext(ctx, getRolePermissionsByRoleID, roleID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetRolePermissionsByRoleIDRow{} + for rows.Next() { + var i GetRolePermissionsByRoleIDRow + if err := rows.Scan(&i.RoleID, &i.PermissionID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getRolesByUserID = `-- name: GetRolesByUserID :many +SELECT r.id, r.name, r.description, r.created_at, r.updated_at, r.deleted_at +FROM roles r +JOIN user_roles ur ON r.id = ur.role_id +WHERE ur.user_id = $1 AND r.deleted_at IS NULL +ORDER BY r.created_at DESC +` + +type GetRolesByUserIDRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +func (q *Queries) GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]GetRolesByUserIDRow, error) { + rows, err := q.db.QueryContext(ctx, getRolesByUserID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetRolesByUserIDRow{} + for rows.Next() { + var i GetRolesByUserIDRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUserRolesByUserID = `-- name: GetUserRolesByUserID :many +SELECT user_id, role_id FROM user_roles WHERE user_id = $1 +` + +type GetUserRolesByUserIDRow struct { + UserID uuid.UUID `json:"user_id"` + RoleID uuid.UUID `json:"role_id"` +} + +func (q *Queries) GetUserRolesByUserID(ctx context.Context, userID uuid.UUID) ([]GetUserRolesByUserIDRow, error) { + rows, err := q.db.QueryContext(ctx, getUserRolesByUserID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUserRolesByUserIDRow{} + for rows.Next() { + var i GetUserRolesByUserIDRow + if err := rows.Scan(&i.UserID, &i.RoleID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const removeRolePermission = `-- name: RemoveRolePermission :exec +DELETE FROM role_permissions WHERE role_id = $1 AND permission_id = $2 +` + +type RemoveRolePermissionParams struct { + RoleID uuid.UUID `json:"role_id"` + PermissionID uuid.UUID `json:"permission_id"` +} + +func (q *Queries) RemoveRolePermission(ctx context.Context, arg RemoveRolePermissionParams) error { + _, err := q.db.ExecContext(ctx, removeRolePermission, arg.RoleID, arg.PermissionID) + return err +} + +const removeUserRole = `-- name: RemoveUserRole :exec +DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2 +` + +type RemoveUserRoleParams struct { + UserID uuid.UUID `json:"user_id"` + RoleID uuid.UUID `json:"role_id"` +} + +func (q *Queries) RemoveUserRole(ctx context.Context, arg RemoveUserRoleParams) error { + _, err := q.db.ExecContext(ctx, removeUserRole, arg.UserID, arg.RoleID) + return err +} + +const updatePermission = `-- name: UpdatePermission :execrows +UPDATE permissions SET name = $2, description = $3, resource = $4, action = $5, updated_at = $6 WHERE id = $1 AND deleted_at IS NULL +` + +type UpdatePermissionParams struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Resource string `json:"resource"` + Action string `json:"action"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (q *Queries) UpdatePermission(ctx context.Context, arg UpdatePermissionParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updatePermission, + arg.ID, + arg.Name, + arg.Description, + arg.Resource, + arg.Action, + arg.UpdatedAt, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const updateRole = `-- name: UpdateRole :execrows +UPDATE roles SET name = $2, description = $3, updated_at = $4 WHERE id = $1 AND deleted_at IS NULL +` + +type UpdateRoleParams struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (q *Queries) UpdateRole(ctx context.Context, arg UpdateRoleParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateRole, + arg.ID, + arg.Name, + arg.Description, + arg.UpdatedAt, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/authorization/infrastructure/persistence/user_role_repository.go b/internal/authorization/infrastructure/persistence/user_role_repository.go index aba9716..2ba9df2 100644 --- a/internal/authorization/infrastructure/persistence/user_role_repository.go +++ b/internal/authorization/infrastructure/persistence/user_role_repository.go @@ -7,6 +7,7 @@ import ( "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/persistence/sqlc" "github.com/google/uuid" ) @@ -19,8 +20,11 @@ func NewUserRoleRepository(db *sql.DB) repository.UserRoleRepository { } func (r *userRoleRepository) Assign(ctx context.Context, ur entity.UserRole) error { - query := `INSERT INTO user_roles (user_id, role_id, created_at) VALUES ($1, $2, NOW()) ON CONFLICT (user_id, role_id) DO NOTHING` - _, err := r.db.ExecContext(ctx, query, ur.UserID, ur.RoleID) + q := sqlc.New(r.db) + err := q.AssignUserRole(ctx, sqlc.AssignUserRoleParams{ + UserID: ur.UserID, + RoleID: ur.RoleID, + }) if err != nil { return fmt.Errorf("assign role: %w", err) } @@ -28,8 +32,11 @@ func (r *userRoleRepository) Assign(ctx context.Context, ur entity.UserRole) err } func (r *userRoleRepository) Remove(ctx context.Context, userID, roleID uuid.UUID) error { - query := `DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2` - _, err := r.db.ExecContext(ctx, query, userID, roleID) + q := sqlc.New(r.db) + err := q.RemoveUserRole(ctx, sqlc.RemoveUserRoleParams{ + UserID: userID, + RoleID: roleID, + }) if err != nil { return fmt.Errorf("remove role: %w", err) } @@ -37,44 +44,30 @@ func (r *userRoleRepository) Remove(ctx context.Context, userID, roleID uuid.UUI } func (r *userRoleRepository) GetByUserID(ctx context.Context, userID uuid.UUID) ([]entity.UserRole, error) { - query := `SELECT user_id, role_id FROM user_roles WHERE user_id = $1` - rows, err := r.db.QueryContext(ctx, query, userID) + q := sqlc.New(r.db) + rows, err := q.GetUserRolesByUserID(ctx, userID) if err != nil { return nil, fmt.Errorf("get user roles: %w", err) } - defer rows.Close() - - var urs []entity.UserRole - for rows.Next() { - var ur entity.UserRole - if err := rows.Scan(&ur.UserID, &ur.RoleID); err != nil { - return nil, fmt.Errorf("scan user role: %w", err) + urs := make([]entity.UserRole, len(rows)) + for i, row := range rows { + urs[i] = entity.UserRole{ + UserID: row.UserID, + RoleID: row.RoleID, } - urs = append(urs, ur) } return urs, nil } func (r *userRoleRepository) GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]*entity.Role, error) { - query := ` - SELECT r.id, r.name, r.description, r.created_at, r.updated_at, r.deleted_at - FROM roles r - JOIN user_roles ur ON r.id = ur.role_id - WHERE ur.user_id = $1 AND r.deleted_at IS NULL - ORDER BY r.created_at DESC` - rows, err := r.db.QueryContext(ctx, query, userID) + q := sqlc.New(r.db) + rows, err := q.GetRolesByUserID(ctx, userID) if err != nil { return nil, fmt.Errorf("get roles by user: %w", err) } - defer rows.Close() - - var roles []*entity.Role - for rows.Next() { - role := &entity.Role{} - if err := rows.Scan(&role.ID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt, &role.DeletedAt); err != nil { - return nil, fmt.Errorf("scan role: %w", err) - } - roles = append(roles, role) + roles := make([]*entity.Role, len(rows)) + for i, row := range rows { + roles[i] = mapRoleRowToEntity(row.ID, row.Name, row.Description, row.CreatedAt, row.UpdatedAt, row.DeletedAt) } return roles, nil } diff --git a/internal/authorization/interfaces/http/handlers.go b/internal/authorization/interfaces/http/handlers.go index 3304e23..7c93a23 100644 --- a/internal/authorization/interfaces/http/handlers.go +++ b/internal/authorization/interfaces/http/handlers.go @@ -2,11 +2,13 @@ package http import ( "encoding/json" - "fmt" "net/http" + "strconv" + "github.com/IDTS-LAB/go-codebase/internal/authorization/application/command" "github.com/IDTS-LAB/go-codebase/internal/authorization/application/dto" - "github.com/IDTS-LAB/go-codebase/internal/authorization/application/service" + "github.com/IDTS-LAB/go-codebase/internal/authorization/application/query" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" "github.com/IDTS-LAB/go-codebase/internal/shared/utils" "github.com/IDTS-LAB/go-codebase/internal/shared/validator" @@ -15,12 +17,13 @@ import ( ) type Handler struct { - svc *service.AuthorizationService - validator *validator.Validator + commandBus cqrs.CommandBus + queryBus cqrs.QueryBus + validator *validator.Validator } -func NewHandler(svc *service.AuthorizationService, v *validator.Validator) *Handler { - return &Handler{svc: svc, validator: v} +func NewHandler(commandBus cqrs.CommandBus, queryBus cqrs.QueryBus, v *validator.Validator) *Handler { + return &Handler{commandBus: commandBus, queryBus: queryBus, validator: v} } // CreateRole godoc @@ -30,9 +33,9 @@ func NewHandler(svc *service.AuthorizationService, v *validator.Validator) *Hand // @Accept json // @Produce json // @Param request body dto.CreateRoleRequest true "Role to create" -// @Success 201 {object} utils.SuccessResponse{data=dto.RoleResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 409 {object} utils.ErrorResponse +// @Success 201 {object} utils.APIResponse{data=dto.RoleResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 409 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/roles [post] func (h *Handler) CreateRole(w http.ResponseWriter, r *http.Request) { @@ -45,12 +48,11 @@ func (h *Handler) CreateRole(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, err.Error()) return } - role, err := h.svc.CreateRole(r.Context(), req.Name, req.Description) - if err != nil { - utils.RespondConflict(w, "role already exists") - return - } - utils.RespondCreated(w, role) + resp, err := h.commandBus.Dispatch(r.Context(), command.CreateRoleCommand{ + Name: req.Name, + Description: req.Description, + }) + utils.HandleCreated(w, r, resp, err) } // ListRoles godoc @@ -60,24 +62,31 @@ func (h *Handler) CreateRole(w http.ResponseWriter, r *http.Request) { // @Produce json // @Param page query int false "Page number" default(1) // @Param per_page query int false "Items per page" default(20) -// @Success 200 {object} utils.SuccessResponse{data=dto.ListResponse} -// @Failure 500 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.ListResponse} +// @Failure 500 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/roles [get] func (h *Handler) ListRoles(w http.ResponseWriter, r *http.Request) { - page, perPage := 1, 20 - if p := r.URL.Query().Get("page"); p != "" { - fmt.Sscanf(p, "%d", &page) + cursorStr := r.URL.Query().Get("cursor") + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 { + limit = n + } } - if pp := r.URL.Query().Get("per_page"); pp != "" { - fmt.Sscanf(pp, "%d", &perPage) + + var cursor *string + if cursorStr != "" { + cursor = &cursorStr } - roles, total, err := h.svc.ListRoles(r.Context(), page, perPage) + + resp, err := h.queryBus.Ask(r.Context(), query.ListRolesQuery{Cursor: cursor, Limit: limit}) if err != nil { - utils.RespondInternalError(w, "failed to list roles") + utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error()) return } - utils.RespondSuccess(w, dto.ListResponse{Items: roles, Total: total, Page: page, PerPage: perPage}) + result := resp.(query.ListRolesResult) + utils.RespondCursorPaginated(w, result.Roles, result.NextCursor, result.PrevCursor, result.HasNext, result.HasPrev, limit) } // GetRole godoc @@ -86,8 +95,8 @@ func (h *Handler) ListRoles(w http.ResponseWriter, r *http.Request) { // @Tags authorization // @Produce json // @Param id path string true "Role ID" -// @Success 200 {object} utils.SuccessResponse{data=dto.RoleResponse} -// @Failure 404 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.RoleResponse} +// @Failure 404 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/roles/{id} [get] func (h *Handler) GetRole(w http.ResponseWriter, r *http.Request) { @@ -96,12 +105,8 @@ func (h *Handler) GetRole(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, "invalid role ID") return } - role, err := h.svc.GetRole(r.Context(), id) - if err != nil { - utils.RespondNotFound(w, "role not found") - return - } - utils.RespondSuccess(w, role) + resp, err := h.queryBus.Ask(r.Context(), query.GetRoleQuery{ID: id}) + utils.Handle(w, r, resp, err) } // UpdateRole godoc @@ -112,9 +117,9 @@ func (h *Handler) GetRole(w http.ResponseWriter, r *http.Request) { // @Produce json // @Param id path string true "Role ID" // @Param request body dto.UpdateRoleRequest true "Fields to update" -// @Success 200 {object} utils.SuccessResponse{data=dto.RoleResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 404 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.RoleResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 404 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/roles/{id} [put] func (h *Handler) UpdateRole(w http.ResponseWriter, r *http.Request) { @@ -124,16 +129,16 @@ func (h *Handler) UpdateRole(w http.ResponseWriter, r *http.Request) { return } var req dto.UpdateRoleRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { utils.RespondBadRequest(w, "invalid request body") return } - role, err := h.svc.UpdateRole(r.Context(), id, req.Name, req.Description) - if err != nil { - utils.RespondNotFound(w, "role not found") - return - } - utils.RespondSuccess(w, role) + resp, err := h.commandBus.Dispatch(r.Context(), command.UpdateRoleCommand{ + ID: id, + Name: req.Name, + Description: req.Description, + }) + utils.Handle(w, r, resp, err) } // DeleteRole godoc @@ -141,8 +146,8 @@ func (h *Handler) UpdateRole(w http.ResponseWriter, r *http.Request) { // @Description Delete a role by ID // @Tags authorization // @Param id path string true "Role ID" -// @Success 200 {object} utils.SuccessResponse -// @Failure 404 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse +// @Failure 404 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/roles/{id} [delete] func (h *Handler) DeleteRole(w http.ResponseWriter, r *http.Request) { @@ -151,11 +156,8 @@ func (h *Handler) DeleteRole(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, "invalid role ID") return } - if err := h.svc.DeleteRole(r.Context(), id); err != nil { - utils.RespondNotFound(w, "role not found") - return - } - utils.RespondSuccess(w, nil) + _, err = h.commandBus.Dispatch(r.Context(), command.DeleteRoleCommand{ID: id}) + utils.HandleNoContent(w, r, err) } // CreatePermission godoc @@ -165,9 +167,9 @@ func (h *Handler) DeleteRole(w http.ResponseWriter, r *http.Request) { // @Accept json // @Produce json // @Param request body dto.CreatePermissionRequest true "Permission to create" -// @Success 201 {object} utils.SuccessResponse{data=dto.PermissionResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 409 {object} utils.ErrorResponse +// @Success 201 {object} utils.APIResponse{data=dto.PermissionResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 409 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/permissions [post] func (h *Handler) CreatePermission(w http.ResponseWriter, r *http.Request) { @@ -180,12 +182,13 @@ func (h *Handler) CreatePermission(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, err.Error()) return } - perm, err := h.svc.CreatePermission(r.Context(), req.Name, req.Description, req.Resource, req.Action) - if err != nil { - utils.RespondConflict(w, "permission already exists") - return - } - utils.RespondCreated(w, perm) + resp, err := h.commandBus.Dispatch(r.Context(), command.CreatePermissionCommand{ + Name: req.Name, + Description: req.Description, + Resource: req.Resource, + Action: req.Action, + }) + utils.HandleCreated(w, r, resp, err) } // ListPermissions godoc @@ -195,24 +198,31 @@ func (h *Handler) CreatePermission(w http.ResponseWriter, r *http.Request) { // @Produce json // @Param page query int false "Page number" default(1) // @Param per_page query int false "Items per page" default(20) -// @Success 200 {object} utils.SuccessResponse{data=dto.ListResponse} -// @Failure 500 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.ListResponse} +// @Failure 500 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/permissions [get] func (h *Handler) ListPermissions(w http.ResponseWriter, r *http.Request) { - page, perPage := 1, 20 - if p := r.URL.Query().Get("page"); p != "" { - fmt.Sscanf(p, "%d", &page) + cursorStr := r.URL.Query().Get("cursor") + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 { + limit = n + } } - if pp := r.URL.Query().Get("per_page"); pp != "" { - fmt.Sscanf(pp, "%d", &perPage) + + var cursor *string + if cursorStr != "" { + cursor = &cursorStr } - perms, total, err := h.svc.ListPermissions(r.Context(), page, perPage) + + resp, err := h.queryBus.Ask(r.Context(), query.ListPermissionsQuery{Cursor: cursor, Limit: limit}) if err != nil { - utils.RespondInternalError(w, "failed to list permissions") + utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error()) return } - utils.RespondSuccess(w, dto.ListResponse{Items: perms, Total: total, Page: page, PerPage: perPage}) + result := resp.(query.ListPermissionsResult) + utils.RespondCursorPaginated(w, result.Permissions, result.NextCursor, result.PrevCursor, result.HasNext, result.HasPrev, limit) } // GetPermission godoc @@ -221,8 +231,8 @@ func (h *Handler) ListPermissions(w http.ResponseWriter, r *http.Request) { // @Tags authorization // @Produce json // @Param id path string true "Permission ID" -// @Success 200 {object} utils.SuccessResponse{data=dto.PermissionResponse} -// @Failure 404 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.PermissionResponse} +// @Failure 404 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/permissions/{id} [get] func (h *Handler) GetPermission(w http.ResponseWriter, r *http.Request) { @@ -231,12 +241,8 @@ func (h *Handler) GetPermission(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, "invalid permission ID") return } - perm, err := h.svc.GetPermission(r.Context(), id) - if err != nil { - utils.RespondNotFound(w, "permission not found") - return - } - utils.RespondSuccess(w, perm) + resp, err := h.queryBus.Ask(r.Context(), query.GetPermissionQuery{ID: id}) + utils.Handle(w, r, resp, err) } // UpdatePermission godoc @@ -247,9 +253,9 @@ func (h *Handler) GetPermission(w http.ResponseWriter, r *http.Request) { // @Produce json // @Param id path string true "Permission ID" // @Param request body dto.UpdatePermissionRequest true "Fields to update" -// @Success 200 {object} utils.SuccessResponse{data=dto.PermissionResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 404 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.PermissionResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 404 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/permissions/{id} [put] func (h *Handler) UpdatePermission(w http.ResponseWriter, r *http.Request) { @@ -259,16 +265,18 @@ func (h *Handler) UpdatePermission(w http.ResponseWriter, r *http.Request) { return } var req dto.UpdatePermissionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { utils.RespondBadRequest(w, "invalid request body") return } - perm, err := h.svc.UpdatePermission(r.Context(), id, req.Name, req.Description, req.Resource, req.Action) - if err != nil { - utils.RespondNotFound(w, "permission not found") - return - } - utils.RespondSuccess(w, perm) + resp, err := h.commandBus.Dispatch(r.Context(), command.UpdatePermissionCommand{ + ID: id, + Name: req.Name, + Description: req.Description, + Resource: req.Resource, + Action: req.Action, + }) + utils.Handle(w, r, resp, err) } // DeletePermission godoc @@ -276,8 +284,8 @@ func (h *Handler) UpdatePermission(w http.ResponseWriter, r *http.Request) { // @Description Delete a permission by ID // @Tags authorization // @Param id path string true "Permission ID" -// @Success 200 {object} utils.SuccessResponse -// @Failure 404 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse +// @Failure 404 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/permissions/{id} [delete] func (h *Handler) DeletePermission(w http.ResponseWriter, r *http.Request) { @@ -286,11 +294,8 @@ func (h *Handler) DeletePermission(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, "invalid permission ID") return } - if err := h.svc.DeletePermission(r.Context(), id); err != nil { - utils.RespondNotFound(w, "permission not found") - return - } - utils.RespondSuccess(w, nil) + _, err = h.commandBus.Dispatch(r.Context(), command.DeletePermissionCommand{ID: id}) + utils.HandleNoContent(w, r, err) } // AssignRole godoc @@ -301,8 +306,8 @@ func (h *Handler) DeletePermission(w http.ResponseWriter, r *http.Request) { // @Produce json // @Param userId path string true "User ID" // @Param request body dto.AssignRoleRequest true "Role to assign" -// @Success 200 {object} utils.SuccessResponse -// @Failure 400 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse +// @Failure 400 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/users/{userId}/roles [post] func (h *Handler) AssignRole(w http.ResponseWriter, r *http.Request) { @@ -315,11 +320,11 @@ func (h *Handler) AssignRole(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, err.Error()) return } - if err := h.svc.AssignRoleToUser(r.Context(), req.UserID, req.RoleID); err != nil { - utils.RespondInternalError(w, "failed to assign role") - return - } - utils.RespondSuccess(w, nil) + _, err := h.commandBus.Dispatch(r.Context(), command.AssignRoleCommand{ + UserID: req.UserID, + RoleID: req.RoleID, + }) + utils.HandleNoContent(w, r, err) } // RemoveRole godoc @@ -328,8 +333,8 @@ func (h *Handler) AssignRole(w http.ResponseWriter, r *http.Request) { // @Tags authorization // @Param userId path string true "User ID" // @Param roleId path string true "Role ID" -// @Success 200 {object} utils.SuccessResponse -// @Failure 400 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse +// @Failure 400 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/users/{userId}/roles/{roleId} [delete] func (h *Handler) RemoveRole(w http.ResponseWriter, r *http.Request) { @@ -343,11 +348,11 @@ func (h *Handler) RemoveRole(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, "invalid role ID") return } - if err := h.svc.RemoveRoleFromUser(r.Context(), userID, roleID); err != nil { - utils.RespondInternalError(w, "failed to remove role") - return - } - utils.RespondSuccess(w, nil) + _, err = h.commandBus.Dispatch(r.Context(), command.UnassignRoleCommand{ + UserID: userID, + RoleID: roleID, + }) + utils.HandleNoContent(w, r, err) } // GetUserRoles godoc @@ -356,8 +361,8 @@ func (h *Handler) RemoveRole(w http.ResponseWriter, r *http.Request) { // @Tags authorization // @Produce json // @Param userId path string true "User ID" -// @Success 200 {object} utils.SuccessResponse{data=[]dto.RoleResponse} -// @Failure 400 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=[]dto.RoleResponse} +// @Failure 400 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/users/{userId}/roles [get] func (h *Handler) GetUserRoles(w http.ResponseWriter, r *http.Request) { @@ -366,12 +371,8 @@ func (h *Handler) GetUserRoles(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, "invalid user ID") return } - roles, err := h.svc.GetUserRoles(r.Context(), userID) - if err != nil { - utils.RespondInternalError(w, "failed to get user roles") - return - } - utils.RespondSuccess(w, roles) + resp, err := h.queryBus.Ask(r.Context(), query.GetUserRolesQuery{UserID: userID}) + utils.Handle(w, r, resp, err) } // AssignPermission godoc @@ -382,8 +383,8 @@ func (h *Handler) GetUserRoles(w http.ResponseWriter, r *http.Request) { // @Produce json // @Param roleId path string true "Role ID" // @Param request body dto.AssignPermissionRequest true "Permission to assign" -// @Success 200 {object} utils.SuccessResponse -// @Failure 400 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse +// @Failure 400 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/roles/{roleId}/permissions [post] func (h *Handler) AssignPermission(w http.ResponseWriter, r *http.Request) { @@ -396,11 +397,11 @@ func (h *Handler) AssignPermission(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, err.Error()) return } - if err := h.svc.AssignPermissionToRole(r.Context(), req.RoleID, req.PermissionID); err != nil { - utils.RespondInternalError(w, "failed to assign permission") - return - } - utils.RespondSuccess(w, nil) + _, err := h.commandBus.Dispatch(r.Context(), command.AssignPermissionCommand{ + RoleID: req.RoleID, + PermissionID: req.PermissionID, + }) + utils.HandleNoContent(w, r, err) } // RemovePermission godoc @@ -409,8 +410,8 @@ func (h *Handler) AssignPermission(w http.ResponseWriter, r *http.Request) { // @Tags authorization // @Param roleId path string true "Role ID" // @Param permissionId path string true "Permission ID" -// @Success 200 {object} utils.SuccessResponse -// @Failure 400 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse +// @Failure 400 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/roles/{roleId}/permissions/{permissionId} [delete] func (h *Handler) RemovePermission(w http.ResponseWriter, r *http.Request) { @@ -424,11 +425,11 @@ func (h *Handler) RemovePermission(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, "invalid permission ID") return } - if err := h.svc.RemovePermissionFromRole(r.Context(), roleID, permID); err != nil { - utils.RespondInternalError(w, "failed to remove permission") - return - } - utils.RespondSuccess(w, nil) + _, err = h.commandBus.Dispatch(r.Context(), command.UnassignPermissionCommand{ + RoleID: roleID, + PermissionID: permID, + }) + utils.HandleNoContent(w, r, err) } // GetRolePermissions godoc @@ -437,8 +438,8 @@ func (h *Handler) RemovePermission(w http.ResponseWriter, r *http.Request) { // @Tags authorization // @Produce json // @Param roleId path string true "Role ID" -// @Success 200 {object} utils.SuccessResponse{data=[]dto.PermissionResponse} -// @Failure 400 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=[]dto.PermissionResponse} +// @Failure 400 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/roles/{roleId}/permissions [get] func (h *Handler) GetRolePermissions(w http.ResponseWriter, r *http.Request) { @@ -447,12 +448,8 @@ func (h *Handler) GetRolePermissions(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, "invalid role ID") return } - perms, err := h.svc.GetRolePermissions(r.Context(), roleID) - if err != nil { - utils.RespondInternalError(w, "failed to get role permissions") - return - } - utils.RespondSuccess(w, perms) + resp, err := h.queryBus.Ask(r.Context(), query.GetRolePermissionsQuery{RoleID: roleID}) + utils.Handle(w, r, resp, err) } // CheckPermission godoc @@ -462,9 +459,9 @@ func (h *Handler) GetRolePermissions(w http.ResponseWriter, r *http.Request) { // @Accept json // @Produce json // @Param request body dto.CheckPermissionRequest true "Permission to check" -// @Success 200 {object} utils.SuccessResponse{data=dto.CheckPermissionResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 401 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.CheckPermissionResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 401 {object} utils.APIResponse // @Security BearerAuth // @Router /auth/sessions/check-permission [post] func (h *Handler) CheckPermission(w http.ResponseWriter, r *http.Request) { @@ -479,18 +476,19 @@ func (h *Handler) CheckPermission(w http.ResponseWriter, r *http.Request) { return } var req dto.CheckPermissionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { utils.RespondBadRequest(w, "invalid request body") return } - if err := h.validator.Validate(req); err != nil { + if err = h.validator.Validate(req); err != nil { utils.RespondBadRequest(w, err.Error()) return } - allowed, err := h.svc.CheckPermission(r.Context(), uid, req.Resource, req.Action) - if err != nil { - utils.RespondInternalError(w, "failed to check permission") - return - } - utils.RespondSuccess(w, dto.CheckPermissionResponse{Allowed: allowed}) -} \ No newline at end of file + resp, err := h.queryBus.Ask(r.Context(), query.CheckPermissionQuery{ + UserID: uid, + Resource: req.Resource, + Action: req.Action, + }) + allowed, _ := resp.(bool) + utils.Handle(w, r, dto.CheckPermissionResponse{Allowed: allowed}, err) +} diff --git a/internal/authorization/interfaces/http/handlers_test.go b/internal/authorization/interfaces/http/handlers_test.go new file mode 100644 index 0000000..438dcdb --- /dev/null +++ b/internal/authorization/interfaces/http/handlers_test.go @@ -0,0 +1,698 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/IDTS-LAB/go-codebase/internal/authorization/application/command" + "github.com/IDTS-LAB/go-codebase/internal/authorization/application/dto" + "github.com/IDTS-LAB/go-codebase/internal/authorization/application/query" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/entity" + coredomain "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" + "github.com/IDTS-LAB/go-codebase/internal/shared/validator" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockHandler struct { + result any + err error +} + +func (h *mockHandler) Handle(ctx context.Context, _ any) (any, error) { + return h.result, h.err +} + +func withChiParams(r *http.Request, params map[string]string) *http.Request { + rctx := chi.NewRouteContext() + for k, v := range params { + rctx.URLParams.Add(k, v) + } + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +func withUserID(r *http.Request, userID uuid.UUID) *http.Request { + return r.WithContext(context.WithValue(r.Context(), middleware.UserIDKey, userID.String())) +} + +func responseMap(t *testing.T, w *httptest.ResponseRecorder) map[string]interface{} { + t.Helper() + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + return resp +} + +func TestCreateRole(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + expected := &entity.Role{ + Entity: coredomain.Entity{ID: roleID}, + Name: "admin", + Description: "Administrator", + } + cmdBus.Register(command.CreateRoleCommand{}, &mockHandler{result: expected}) + + body, _ := json.Marshal(dto.CreateRoleRequest{Name: "admin", Description: "Administrator"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/roles", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.CreateRole(w, r) + + assert.Equal(t, http.StatusCreated, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) + + t.Run("validation error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/roles", bytes.NewReader([]byte(`{"name":""}`))) + r.Header.Set("Content-Type", "application/json") + + h.CreateRole(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + resp := responseMap(t, w) + assert.False(t, resp["success"].(bool)) + }) +} + +func TestListRoles(t *testing.T) { + t.Run("success with pagination", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roles := query.ListRolesResult{ + Roles: []*entity.Role{{Name: "admin"}}, + } + qBus.Register(query.ListRolesQuery{}, &mockHandler{result: roles}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/roles", nil) + + h.ListRoles(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + assert.NotNil(t, resp["meta"]) + }) +} + +func TestGetRole(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + expected := &entity.Role{ + Entity: coredomain.Entity{ID: roleID}, + Name: "admin", + } + qBus.Register(query.GetRoleQuery{}, &mockHandler{result: expected}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/roles/"+roleID.String(), nil) + r = withChiParams(r, map[string]string{"id": roleID.String()}) + + h.GetRole(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) + + t.Run("invalid UUID", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/roles/invalid", nil) + r = withChiParams(r, map[string]string{"id": "invalid"}) + + h.GetRole(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("not found", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + qBus.Register(query.GetRoleQuery{}, &mockHandler{result: nil, err: coredomain.ErrNotFound}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/roles/"+roleID.String(), nil) + r = withChiParams(r, map[string]string{"id": roleID.String()}) + + h.GetRole(w, r) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) +} + +func TestUpdateRole(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + updated := &entity.Role{ + Entity: coredomain.Entity{ID: roleID}, + Name: "admin", + Description: "Updated", + } + cmdBus.Register(command.UpdateRoleCommand{}, &mockHandler{result: updated}) + + body, _ := json.Marshal(dto.UpdateRoleRequest{Name: "admin", Description: "Updated"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/roles/"+roleID.String(), bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": roleID.String()}) + + h.UpdateRole(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) +} + +func TestDeleteRole(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + cmdBus.Register(command.DeleteRoleCommand{}, &mockHandler{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/roles/"+roleID.String(), nil) + r = withChiParams(r, map[string]string{"id": roleID.String()}) + + h.DeleteRole(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) +} + +func TestCreatePermission(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + permID := uuid.New() + expected := &entity.Permission{ + Entity: coredomain.Entity{ID: permID}, + Name: "read", + Resource: "users", + Action: "read", + } + cmdBus.Register(command.CreatePermissionCommand{}, &mockHandler{result: expected}) + + body, _ := json.Marshal(dto.CreatePermissionRequest{ + Name: "read", Description: "Read users", Resource: "users", Action: "read", + }) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/permissions", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.CreatePermission(w, r) + + assert.Equal(t, http.StatusCreated, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) + + t.Run("validation error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/permissions", bytes.NewReader([]byte(`{}`))) + r.Header.Set("Content-Type", "application/json") + + h.CreatePermission(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + resp := responseMap(t, w) + assert.False(t, resp["success"].(bool)) + }) +} + +func TestListPermissions(t *testing.T) { + t.Run("success with pagination", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + perms := query.ListPermissionsResult{ + Permissions: []*entity.Permission{{Name: "read", Resource: "users", Action: "read"}}, + } + qBus.Register(query.ListPermissionsQuery{}, &mockHandler{result: perms}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/permissions", nil) + + h.ListPermissions(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + assert.NotNil(t, resp["meta"]) + }) +} + +func TestGetPermission(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + permID := uuid.New() + expected := &entity.Permission{ + Entity: coredomain.Entity{ID: permID}, + Name: "read", + Resource: "users", + Action: "read", + } + qBus.Register(query.GetPermissionQuery{}, &mockHandler{result: expected}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/permissions/"+permID.String(), nil) + r = withChiParams(r, map[string]string{"id": permID.String()}) + + h.GetPermission(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) + + t.Run("not found", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + permID := uuid.New() + qBus.Register(query.GetPermissionQuery{}, &mockHandler{result: nil, err: coredomain.ErrNotFound}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/permissions/"+permID.String(), nil) + r = withChiParams(r, map[string]string{"id": permID.String()}) + + h.GetPermission(w, r) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) +} + +func TestUpdatePermission(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + permID := uuid.New() + updated := &entity.Permission{ + Entity: coredomain.Entity{ID: permID}, + Name: "write", + Resource: "users", + Action: "write", + } + cmdBus.Register(command.UpdatePermissionCommand{}, &mockHandler{result: updated}) + + body, _ := json.Marshal(dto.UpdatePermissionRequest{ + Name: "write", Description: "Write users", Resource: "users", Action: "write", + }) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/permissions/"+permID.String(), bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": permID.String()}) + + h.UpdatePermission(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) +} + +func TestDeletePermission(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + permID := uuid.New() + cmdBus.Register(command.DeletePermissionCommand{}, &mockHandler{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/permissions/"+permID.String(), nil) + r = withChiParams(r, map[string]string{"id": permID.String()}) + + h.DeletePermission(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) +} + +func TestAssignRole(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + userID := uuid.New() + roleID := uuid.New() + cmdBus.Register(command.AssignRoleCommand{}, &mockHandler{}) + + body, _ := json.Marshal(dto.AssignRoleRequest{UserID: userID, RoleID: roleID}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/users/"+userID.String()+"/roles", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.AssignRole(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) + + t.Run("validation error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/users/"+uuid.New().String()+"/roles", bytes.NewReader([]byte(`{}`))) + r.Header.Set("Content-Type", "application/json") + + h.AssignRole(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) +} + +func TestRemoveRole(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + userID := uuid.New() + roleID := uuid.New() + cmdBus.Register(command.UnassignRoleCommand{}, &mockHandler{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/users/"+userID.String()+"/roles/"+roleID.String(), nil) + r = withChiParams(r, map[string]string{"userId": userID.String(), "roleId": roleID.String()}) + + h.RemoveRole(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) +} + +func TestGetUserRoles(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + userID := uuid.New() + roles := []*entity.Role{{Name: "admin"}} + qBus.Register(query.GetUserRolesQuery{}, &mockHandler{result: roles}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/users/"+userID.String()+"/roles", nil) + r = withChiParams(r, map[string]string{"userId": userID.String()}) + + h.GetUserRoles(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) +} + +func TestAssignPermission(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + permID := uuid.New() + cmdBus.Register(command.AssignPermissionCommand{}, &mockHandler{}) + + body, _ := json.Marshal(dto.AssignPermissionRequest{RoleID: roleID, PermissionID: permID}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/roles/"+roleID.String()+"/permissions", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.AssignPermission(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) + + t.Run("validation error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/roles/"+uuid.New().String()+"/permissions", bytes.NewReader([]byte(`{}`))) + r.Header.Set("Content-Type", "application/json") + + h.AssignPermission(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) +} + +func TestRemovePermission(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + permID := uuid.New() + cmdBus.Register(command.UnassignPermissionCommand{}, &mockHandler{}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/roles/"+roleID.String()+"/permissions/"+permID.String(), nil) + r = withChiParams(r, map[string]string{"roleId": roleID.String(), "permissionId": permID.String()}) + + h.RemovePermission(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) +} + +func TestGetRolePermissions(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + perms := []*entity.Permission{{Name: "read", Resource: "users", Action: "read"}} + qBus.Register(query.GetRolePermissionsQuery{}, &mockHandler{result: perms}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/roles/"+roleID.String()+"/permissions", nil) + r = withChiParams(r, map[string]string{"roleId": roleID.String()}) + + h.GetRolePermissions(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) +} + +func TestCheckPermission(t *testing.T) { + t.Run("success", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + userID := uuid.New() + qBus.Register(query.CheckPermissionQuery{}, &mockHandler{result: true}) + + body, _ := json.Marshal(dto.CheckPermissionRequest{Resource: "users", Action: "read"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/check-permission", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = withUserID(r, userID) + + h.CheckPermission(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + resp := responseMap(t, w) + assert.True(t, resp["success"].(bool)) + }) + + t.Run("unauthorized", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + body, _ := json.Marshal(dto.CheckPermissionRequest{Resource: "users", Action: "read"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/check-permission", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + + h.CheckPermission(w, r) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("error from invalid context userID", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + body, _ := json.Marshal(dto.CheckPermissionRequest{Resource: "users", Action: "read"}) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/check-permission", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = r.WithContext(context.WithValue(r.Context(), middleware.UserIDKey, "not-a-uuid")) + + h.CheckPermission(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) +} + +func TestHandlerErrors(t *testing.T) { + t.Run("create role bad JSON", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/roles", bytes.NewReader([]byte(`{invalid json`))) + r.Header.Set("Content-Type", "application/json") + + h.CreateRole(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("update role bad JSON", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/roles/"+roleID.String(), bytes.NewReader([]byte(`{invalid}`))) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": roleID.String()}) + + h.UpdateRole(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("update role invalid UUID", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPut, "/roles/invalid", bytes.NewReader([]byte(`{"name":"admin"}`))) + r.Header.Set("Content-Type", "application/json") + r = withChiParams(r, map[string]string{"id": "invalid"}) + + h.UpdateRole(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("delete role error", func(t *testing.T) { + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + v := validator.New() + h := NewHandler(cmdBus, qBus, v) + + roleID := uuid.New() + cmdBus.Register(command.DeleteRoleCommand{}, &mockHandler{err: errors.New("db error")}) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodDelete, "/roles/"+roleID.String(), nil) + r = withChiParams(r, map[string]string{"id": roleID.String()}) + + h.DeleteRole(w, r) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + }) +} diff --git a/internal/authorization/module.go b/internal/authorization/module.go index ff369b4..960c7a5 100644 --- a/internal/authorization/module.go +++ b/internal/authorization/module.go @@ -1,10 +1,15 @@ package authorization import ( - "github.com/IDTS-LAB/go-codebase/internal/authorization/application/service" + "github.com/IDTS-LAB/go-codebase/internal/authorization/application" + "github.com/IDTS-LAB/go-codebase/internal/authorization/application/command" + "github.com/IDTS-LAB/go-codebase/internal/authorization/application/query" + "github.com/IDTS-LAB/go-codebase/internal/authorization/domain/repository" "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/casbin" "github.com/IDTS-LAB/go-codebase/internal/authorization/infrastructure/persistence" httpHandler "github.com/IDTS-LAB/go-codebase/internal/authorization/interfaces/http" + "github.com/IDTS-LAB/go-codebase/internal/authorization/public" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" "go.uber.org/fx" ) @@ -16,8 +21,41 @@ var Module = fx.Module("authorization", persistence.NewPermissionRepository, persistence.NewUserRoleRepository, persistence.NewRolePermissionRepository, - casbin.NewPolicyLoader, - service.NewAuthorizationService, + casbin.NewAdapter, httpHandler.NewHandler, + fx.Annotate(application.NewAuthorizationProvider, fx.As(new(public.AuthorizationProvider))), ), + + fx.Invoke(registerHandlers), ) + +func registerHandlers( + commandBus cqrs.CommandBus, + queryBus cqrs.QueryBus, + roleRepo repository.RoleRepository, + permRepo repository.PermissionRepository, + userRoleRepo repository.UserRoleRepository, + rolePermRepo repository.RolePermissionRepository, + enforcer *casbin.Enforcer, +) { + // Commands + commandBus.Register(command.CreateRoleCommand{}, command.NewCreateRoleHandler(roleRepo)) + commandBus.Register(command.UpdateRoleCommand{}, command.NewUpdateRoleHandler(roleRepo)) + commandBus.Register(command.DeleteRoleCommand{}, command.NewDeleteRoleHandler(roleRepo)) + commandBus.Register(command.CreatePermissionCommand{}, command.NewCreatePermissionHandler(permRepo)) + commandBus.Register(command.UpdatePermissionCommand{}, command.NewUpdatePermissionHandler(permRepo)) + commandBus.Register(command.DeletePermissionCommand{}, command.NewDeletePermissionHandler(permRepo)) + commandBus.Register(command.AssignRoleCommand{}, command.NewAssignRoleHandler(roleRepo, userRoleRepo, enforcer)) + commandBus.Register(command.UnassignRoleCommand{}, command.NewUnassignRoleHandler(userRoleRepo, enforcer)) + commandBus.Register(command.AssignPermissionCommand{}, command.NewAssignPermissionHandler(roleRepo, permRepo, rolePermRepo, enforcer)) + commandBus.Register(command.UnassignPermissionCommand{}, command.NewUnassignPermissionHandler(rolePermRepo, enforcer)) + + // Queries + queryBus.Register(query.GetRoleQuery{}, query.NewGetRoleHandler(roleRepo)) + queryBus.Register(query.ListRolesQuery{}, query.NewListRolesHandler(roleRepo)) + queryBus.Register(query.GetPermissionQuery{}, query.NewGetPermissionHandler(permRepo)) + queryBus.Register(query.ListPermissionsQuery{}, query.NewListPermissionsHandler(permRepo)) + queryBus.Register(query.GetUserRolesQuery{}, query.NewGetUserRolesHandler(userRoleRepo)) + queryBus.Register(query.GetRolePermissionsQuery{}, query.NewGetRolePermissionsHandler(rolePermRepo)) + queryBus.Register(query.CheckPermissionQuery{}, query.NewCheckPermissionHandler(enforcer)) +} diff --git a/internal/authorization/public/provider.go b/internal/authorization/public/provider.go new file mode 100644 index 0000000..7a56ce7 --- /dev/null +++ b/internal/authorization/public/provider.go @@ -0,0 +1,11 @@ +package public + +import ( + "context" + + "github.com/google/uuid" +) + +type AuthorizationProvider interface { + GetUserRoles(ctx context.Context, userID uuid.UUID) ([]string, error) +} diff --git a/internal/core/domain/auth.go b/internal/core/domain/auth.go index 09993eb..811ad90 100644 --- a/internal/core/domain/auth.go +++ b/internal/core/domain/auth.go @@ -1,13 +1,14 @@ package domain type TokenService interface { - GenerateToken(userID, email, role string) (string, error) + GenerateToken(claims *TokenClaims) (string, error) ValidateToken(tokenString string) (*TokenClaims, error) } type TokenClaims struct { - UserID string - Email string - Role string - JTI string + UserID string + Email string + Role string + JTI string + TenantID string } diff --git a/internal/core/domain/email.go b/internal/core/domain/email.go new file mode 100644 index 0000000..d38d2ba --- /dev/null +++ b/internal/core/domain/email.go @@ -0,0 +1,8 @@ +package domain + +type Emailer interface { + SendVerification(to, name, token string) error + SendPasswordReset(to, name, token string) error + SendWelcome(to, name string) error + SendInvite(to, name, inviterName string) error +} diff --git a/internal/core/domain/errors.go b/internal/core/domain/errors.go index ca6dc17..99c1ed3 100644 --- a/internal/core/domain/errors.go +++ b/internal/core/domain/errors.go @@ -3,17 +3,17 @@ package domain import "errors" var ( - ErrNotFound = errors.New("entity not found") + ErrNotFound = errors.New("entity not found") ErrAlreadyExists = errors.New("entity already exists") - ErrInvalidID = errors.New("invalid entity ID") - ErrDeleted = errors.New("entity is deleted") - ErrConflict = errors.New("entity conflict") - ErrValidation = errors.New("validation failed") - ErrForbidden = errors.New("forbidden") - ErrUnauthorized = errors.New("unauthorized") + ErrInvalidID = errors.New("invalid entity ID") + ErrDeleted = errors.New("entity is deleted") + ErrConflict = errors.New("entity conflict") + ErrValidation = errors.New("validation failed") + ErrForbidden = errors.New("forbidden") + ErrUnauthorized = errors.New("unauthorized") ) -type DomainError struct { +type DomainError struct { //nolint:revive Err error Code string Message string diff --git a/internal/core/domain/events.go b/internal/core/domain/events.go index 1ad5abb..a8d76b8 100644 --- a/internal/core/domain/events.go +++ b/internal/core/domain/events.go @@ -1,6 +1,6 @@ package domain -type DomainEvent interface { +type DomainEvent interface { //nolint:revive EventType() string OccurredAt() interface{} } diff --git a/internal/core/domain/timestamp.go b/internal/core/domain/timestamp.go index 108d5a8..169748c 100644 --- a/internal/core/domain/timestamp.go +++ b/internal/core/domain/timestamp.go @@ -50,7 +50,7 @@ func (t Timestamp) MarshalText() ([]byte, error) { return []byte(t.value.Format(time.RFC3339)), nil } -func (t Timestamp) UnmarshalText(data []byte) error { +func (t *Timestamp) UnmarshalText(data []byte) error { parsed, err := time.Parse(time.RFC3339, string(data)) if err != nil { return fmt.Errorf("parse timestamp: %w", err) @@ -63,7 +63,7 @@ func (t Timestamp) Value() (driver.Value, error) { return t.value, nil } -func (t Timestamp) Scan(src interface{}) error { +func (t *Timestamp) Scan(src interface{}) error { switch v := src.(type) { case time.Time: t.value = v diff --git a/internal/infrastructure/auth/jwt.go b/internal/infrastructure/auth/jwt.go index 18b0a2b..e39ae46 100644 --- a/internal/infrastructure/auth/jwt.go +++ b/internal/infrastructure/auth/jwt.go @@ -7,7 +7,6 @@ import ( "github.com/IDTS-LAB/go-codebase/internal/core/domain" "github.com/IDTS-LAB/go-codebase/internal/shared/config" "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" "go.uber.org/fx" ) @@ -26,22 +25,24 @@ func NewJWTTokenService(cfg *config.Config) domain.TokenService { } type jwtClaims struct { - UserID string `json:"user_id"` - Email string `json:"email"` - Role string `json:"role"` + UserID string `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + TenantID string `json:"tenant_id"` jwt.RegisteredClaims } -func (s *JWTTokenService) GenerateToken(userID, email, role string) (string, error) { +func (s *JWTTokenService) GenerateToken(tc *domain.TokenClaims) (string, error) { claims := jwtClaims{ - UserID: userID, - Email: email, - Role: role, + UserID: tc.UserID, + Email: tc.Email, + Role: tc.Role, + TenantID: tc.TenantID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.expiration)), IssuedAt: jwt.NewNumericDate(time.Now()), - Subject: userID, - ID: uuid.New().String(), + Subject: tc.UserID, + ID: tc.JTI, }, } @@ -71,9 +72,10 @@ func (s *JWTTokenService) ValidateToken(tokenString string) (*domain.TokenClaims } return &domain.TokenClaims{ - UserID: claims.UserID, - Email: claims.Email, - Role: claims.Role, - JTI: claims.ID, + UserID: claims.UserID, + Email: claims.Email, + Role: claims.Role, + JTI: claims.ID, + TenantID: claims.TenantID, }, nil } diff --git a/internal/infrastructure/email/console.go b/internal/infrastructure/email/console.go new file mode 100644 index 0000000..2a82ac9 --- /dev/null +++ b/internal/infrastructure/email/console.go @@ -0,0 +1,72 @@ +package email + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/core/domain" +) + +type ConsoleMailer struct { + from string + fromName string + frontendURL string + log domain.Logger +} + +func NewConsoleMailer(from, fromName, frontendURL string, log domain.Logger) *ConsoleMailer { + return &ConsoleMailer{from: from, fromName: fromName, frontendURL: frontendURL, log: log} +} + +func (m *ConsoleMailer) SendVerification(to, name, token string) error { + verifyURL := m.frontendURL + "/verify-email?token=" + token + content, err := renderTemplate("verification", TemplateData{Name: name, VerifyURL: verifyURL}) + if err != nil { + return err + } + m.log.Info(context.Background(), "[EMAIL] verification", + domain.String("to", to), + domain.String("subject", "Verify your email"), + domain.String("content", content), + ) + return nil +} + +func (m *ConsoleMailer) SendPasswordReset(to, name, token string) error { + resetURL := m.frontendURL + "/reset-password?token=" + token + content, err := renderTemplate("password_reset", TemplateData{Name: name, ResetURL: resetURL}) + if err != nil { + return err + } + m.log.Info(context.Background(), "[EMAIL] password reset", + domain.String("to", to), + domain.String("subject", "Reset your password"), + domain.String("content", content), + ) + return nil +} + +func (m *ConsoleMailer) SendWelcome(to, name string) error { + content, err := renderTemplate("welcome", TemplateData{Name: name}) + if err != nil { + return err + } + m.log.Info(context.Background(), "[EMAIL] welcome", + domain.String("to", to), + domain.String("subject", "Welcome "+name+"!"), + domain.String("content", content), + ) + return nil +} + +func (m *ConsoleMailer) SendInvite(to, name, inviterName string) error { + content, err := renderTemplate("invite", TemplateData{Name: name, InviterName: inviterName}) + if err != nil { + return err + } + m.log.Info(context.Background(), "[EMAIL] invite", + domain.String("to", to), + domain.String("subject", inviterName+" invited you"), + domain.String("content", content), + ) + return nil +} diff --git a/internal/infrastructure/email/email.go b/internal/infrastructure/email/email.go new file mode 100644 index 0000000..9bdc2f1 --- /dev/null +++ b/internal/infrastructure/email/email.go @@ -0,0 +1,34 @@ +package email + +import ( + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/config" + "go.uber.org/fx" +) + +var Module = fx.Module("email", fx.Provide(NewEmailer)) + +func NewEmailer(cfg *config.Config, log domain.Logger) domain.Emailer { + switch cfg.Email.Provider { + case "smtp": + return NewSMTPMailer( + cfg.Email.SMTP.Host, + cfg.Email.SMTP.Port, + cfg.Email.SMTP.Username, + cfg.Email.SMTP.Password, + cfg.Email.SMTP.UseTLS, + cfg.Email.From, + cfg.Email.FromName, + cfg.Email.FrontendURL, + ) + case "sendgrid": + return NewSendGridMailer( + cfg.Email.SendGrid.APIKey, + cfg.Email.From, + cfg.Email.FromName, + cfg.Email.FrontendURL, + ) + default: + return NewConsoleMailer(cfg.Email.From, cfg.Email.FromName, cfg.Email.FrontendURL, log) + } +} diff --git a/internal/infrastructure/email/email_test.go b/internal/infrastructure/email/email_test.go new file mode 100644 index 0000000..9a4f18e --- /dev/null +++ b/internal/infrastructure/email/email_test.go @@ -0,0 +1,136 @@ +package email + +import ( + "context" + "fmt" + "strings" + "sync" + "testing" + + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/config" +) + +type capturingLogger struct { + mu sync.Mutex + entries []string +} + +func (l *capturingLogger) Info(_ context.Context, msg string, fields ...domain.Field) { + l.mu.Lock() + defer l.mu.Unlock() + var sb strings.Builder + sb.WriteString(msg) + for _, f := range fields { + sb.WriteString(" ") + sb.WriteString(f.Key) + sb.WriteString("=") + if s, ok := f.Value.(string); ok { + sb.WriteString(s) + } else if e, ok := f.Value.(error); ok { + sb.WriteString(e.Error()) + } + } + l.entries = append(l.entries, sb.String()) +} + +func (l *capturingLogger) Debug(_ context.Context, _ string, _ ...domain.Field) {} +func (l *capturingLogger) Warn(_ context.Context, _ string, _ ...domain.Field) {} +func (l *capturingLogger) Error(_ context.Context, _ string, _ ...domain.Field) {} +func (l *capturingLogger) Fatal(_ context.Context, _ string, _ ...domain.Field) {} +func (l *capturingLogger) With(_ ...domain.Field) domain.Logger { return l } + +func (l *capturingLogger) output() string { + l.mu.Lock() + defer l.mu.Unlock() + return strings.Join(l.entries, "\n") +} + +func TestConsoleMailer(t *testing.T) { + logger := &capturingLogger{} + mailer := NewConsoleMailer("test@example.com", "Test App", "http://localhost:3000", logger) + + if err := mailer.SendVerification("user@test.com", "Test User", "abc123"); err != nil { + t.Errorf("SendVerification failed: %v", err) + } + output := logger.output() + if !strings.Contains(output, "user@test.com") { + t.Error("SendVerification should log recipient email") + } + if !strings.Contains(output, "abc123") { + t.Error("SendVerification should log the token") + } + + logger.entries = nil + if err := mailer.SendPasswordReset("user@test.com", "Test User", "xyz789"); err != nil { + t.Errorf("SendPasswordReset failed: %v", err) + } + output = logger.output() + if !strings.Contains(output, "user@test.com") { + t.Error("SendPasswordReset should log recipient email") + } + if !strings.Contains(output, "xyz789") { + t.Error("SendPasswordReset should log the token") + } + + logger.entries = nil + if err := mailer.SendWelcome("user@test.com", "Test User"); err != nil { + t.Errorf("SendWelcome failed: %v", err) + } + output = logger.output() + if !strings.Contains(output, "user@test.com") { + t.Error("SendWelcome should log recipient email") + } + if !strings.Contains(output, "Test User") { + t.Error("SendWelcome should log the name") + } + + logger.entries = nil + if err := mailer.SendInvite("user@test.com", "Test User", "Admin"); err != nil { + t.Errorf("SendInvite failed: %v", err) + } + output = logger.output() + if !strings.Contains(output, "user@test.com") { + t.Error("SendInvite should log recipient email") + } + if !strings.Contains(output, "Admin") { + t.Error("SendInvite should log the inviter name") + } +} + +func TestNewEmailer(t *testing.T) { + logger := &capturingLogger{} + tests := []struct { + name string + provider string + wantType string + }{ + {"console", "console", "*email.ConsoleMailer"}, + {"smtp", "smtp", "*email.SMTPMailer"}, + {"sendgrid", "sendgrid", "*email.SendGridMailer"}, + {"unknown defaults to console", "", "*email.ConsoleMailer"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{} + cfg.Email.Provider = tt.provider + cfg.Email.From = "test@example.com" + cfg.Email.FromName = "Test" + cfg.Email.FrontendURL = "http://localhost:3000" + cfg.Email.SMTP.Host = "localhost" + cfg.Email.SMTP.Port = 587 + cfg.Email.SendGrid.APIKey = "test-key" + + mailer := NewEmailer(cfg, logger) + if mailer == nil { + t.Fatal("NewEmailer returned nil") + } + + gotType := fmt.Sprintf("%T", mailer) + if gotType != tt.wantType { + t.Errorf("provider %q: got %s, want %s", tt.provider, gotType, tt.wantType) + } + }) + } +} diff --git a/internal/infrastructure/email/renderer.go b/internal/infrastructure/email/renderer.go new file mode 100644 index 0000000..a130f22 --- /dev/null +++ b/internal/infrastructure/email/renderer.go @@ -0,0 +1,28 @@ +package email + +import ( + "bytes" + "embed" + "html/template" +) + +//go:embed templates/*.html +var templateFS embed.FS + +var templates = template.Must(template.ParseFS(templateFS, "templates/*.html")) + +type TemplateData struct { + Name string + VerifyURL string + ResetURL string + InviteURL string + InviterName string +} + +func renderTemplate(name string, data TemplateData) (string, error) { + var buf bytes.Buffer + if err := templates.ExecuteTemplate(&buf, name+".html", data); err != nil { + return "", err + } + return buf.String(), nil +} diff --git a/internal/infrastructure/email/renderer_test.go b/internal/infrastructure/email/renderer_test.go new file mode 100644 index 0000000..3ba066f --- /dev/null +++ b/internal/infrastructure/email/renderer_test.go @@ -0,0 +1,110 @@ +package email + +import ( + "strings" + "testing" +) + +func TestRenderTemplate(t *testing.T) { + tests := []struct { + name string + template string + data TemplateData + wantErr bool + contains []string + notContains []string + }{ + { + name: "verification template renders name and verify URL", + template: "verification", + data: TemplateData{ + Name: "Test User", + VerifyURL: "http://localhost:3000/verify-email?token=abc123", + }, + contains: []string{"Test User", "abc123", "Verify Your Email"}, + }, + { + name: "password reset template renders name and reset URL", + template: "password_reset", + data: TemplateData{ + Name: "Test User", + ResetURL: "http://localhost:3000/reset-password?token=xyz789", + }, + contains: []string{"Test User", "xyz789", "Reset Your Password"}, + }, + { + name: "welcome template renders name", + template: "welcome", + data: TemplateData{ + Name: "Test User", + }, + contains: []string{"Test User", "Welcome"}, + }, + { + name: "invite template renders name, inviter, and invite URL", + template: "invite", + data: TemplateData{ + Name: "Test User", + InviterName: "Admin", + InviteURL: "http://localhost:3000/invite?token=def456", + }, + contains: []string{"Test User", "Admin", "def456", "You've Been Invited"}, + }, + { + name: "verification template with empty data still renders", + template: "verification", + data: TemplateData{}, + contains: []string{"Verify Your Email"}, + }, + { + name: "nonexistent template returns error", + template: "nonexistent", + data: TemplateData{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content, err := renderTemplate(tt.template, tt.data) + if tt.wantErr { + if err == nil { + t.Errorf("renderTemplate(%q) expected error, got nil", tt.template) + } + return + } + if err != nil { + t.Fatalf("renderTemplate(%q) failed: %v", tt.template, err) + } + if content == "" { + t.Fatalf("renderTemplate(%q) returned empty content", tt.template) + } + for _, s := range tt.contains { + if !strings.Contains(content, s) { + t.Errorf("renderTemplate(%q) missing %q", tt.template, s) + } + } + for _, s := range tt.notContains { + if strings.Contains(content, s) { + t.Errorf("renderTemplate(%q) should not contain %q", tt.template, s) + } + } + }) + } +} + +func TestRenderTemplateOutputIsValidHTML(t *testing.T) { + content, err := renderTemplate("verification", TemplateData{ + Name: "Test", + VerifyURL: "http://example.com/verify", + }) + if err != nil { + t.Fatalf("renderTemplate failed: %v", err) + } + if !strings.HasPrefix(content, "") { + t.Error("verification template does not start with ") + } + if !strings.HasSuffix(strings.TrimSpace(content), "") { + t.Error("verification template does not end with ") + } +} diff --git a/internal/infrastructure/email/sendgrid.go b/internal/infrastructure/email/sendgrid.go new file mode 100644 index 0000000..7fd53f4 --- /dev/null +++ b/internal/infrastructure/email/sendgrid.go @@ -0,0 +1,72 @@ +package email + +import ( + "fmt" + + "github.com/sendgrid/sendgrid-go" + "github.com/sendgrid/sendgrid-go/helpers/mail" +) + +type SendGridMailer struct { + client *sendgrid.Client + from string + fromName string + frontendURL string +} + +func NewSendGridMailer(apiKey, from, fromName, frontendURL string) *SendGridMailer { + return &SendGridMailer{ + client: sendgrid.NewSendClient(apiKey), + from: from, + fromName: fromName, + frontendURL: frontendURL, + } +} + +func (m *SendGridMailer) SendVerification(to, name, token string) error { + subject := "Verify your email address" + verifyURL := m.frontendURL + "/verify-email?token=" + token + htmlContent, err := renderTemplate("verification", TemplateData{Name: name, VerifyURL: verifyURL}) + if err != nil { + return err + } + return m.send(to, subject, htmlContent) +} + +func (m *SendGridMailer) SendPasswordReset(to, name, token string) error { + subject := "Reset your password" + resetURL := m.frontendURL + "/reset-password?token=" + token + htmlContent, err := renderTemplate("password_reset", TemplateData{Name: name, ResetURL: resetURL}) + if err != nil { + return err + } + return m.send(to, subject, htmlContent) +} + +func (m *SendGridMailer) SendWelcome(to, name string) error { + subject := fmt.Sprintf("Welcome %s!", name) + htmlContent, err := renderTemplate("welcome", TemplateData{Name: name}) + if err != nil { + return err + } + return m.send(to, subject, htmlContent) +} + +func (m *SendGridMailer) SendInvite(to, name, inviterName string) error { + subject := fmt.Sprintf("%s invited you to join", inviterName) + htmlContent, err := renderTemplate("invite", TemplateData{Name: name, InviterName: inviterName}) + if err != nil { + return err + } + return m.send(to, subject, htmlContent) +} + +func (m *SendGridMailer) send(to, subject, htmlContent string) error { + from := mail.NewEmail(m.fromName, m.from) + message := mail.NewSingleEmail(from, subject, mail.NewEmail(to, to), "", htmlContent) + _, err := m.client.Send(message) + if err != nil { + return fmt.Errorf("sendgrid: send email: %w", err) + } + return nil +} diff --git a/internal/infrastructure/email/sendgrid_test.go b/internal/infrastructure/email/sendgrid_test.go new file mode 100644 index 0000000..0238ca0 --- /dev/null +++ b/internal/infrastructure/email/sendgrid_test.go @@ -0,0 +1,22 @@ +package email + +import "testing" + +func TestSendGridMailer(t *testing.T) { + mailer := NewSendGridMailer("test-api-key", "test@example.com", "Test", "http://localhost:3000") + if mailer == nil { + t.Fatal("NewSendGridMailer returned nil") + } + if mailer.client == nil { + t.Fatal("expected client to be non-nil") + } + if mailer.from != "test@example.com" { + t.Errorf("expected from 'test@example.com', got '%s'", mailer.from) + } + if mailer.fromName != "Test" { + t.Errorf("expected fromName 'Test', got '%s'", mailer.fromName) + } + if mailer.frontendURL != "http://localhost:3000" { + t.Errorf("expected frontendURL 'http://localhost:3000', got '%s'", mailer.frontendURL) + } +} diff --git a/internal/infrastructure/email/smtp.go b/internal/infrastructure/email/smtp.go new file mode 100644 index 0000000..50ff6e8 --- /dev/null +++ b/internal/infrastructure/email/smtp.go @@ -0,0 +1,131 @@ +package email + +import ( + "crypto/tls" + "fmt" + "net/smtp" +) + +type SMTPMailer struct { + host string + port int + username string + password string + useTLS bool + from string + fromName string + frontendURL string +} + +func NewSMTPMailer(host string, port int, username, password string, useTLS bool, from, fromName, frontendURL string) *SMTPMailer { + return &SMTPMailer{ + host: host, port: port, username: username, + password: password, useTLS: useTLS, from: from, fromName: fromName, + frontendURL: frontendURL, + } +} + +func (m *SMTPMailer) SendVerification(to, name, token string) error { + subject := "Verify your email address" + verifyURL := m.frontendURL + "/verify-email?token=" + token + content, err := renderTemplate("verification", TemplateData{Name: name, VerifyURL: verifyURL}) + if err != nil { + return err + } + return m.send(to, subject, content) +} + +func (m *SMTPMailer) SendPasswordReset(to, name, token string) error { + subject := "Reset your password" + resetURL := m.frontendURL + "/reset-password?token=" + token + content, err := renderTemplate("password_reset", TemplateData{Name: name, ResetURL: resetURL}) + if err != nil { + return err + } + return m.send(to, subject, content) +} + +func (m *SMTPMailer) SendWelcome(to, name string) error { + subject := fmt.Sprintf("Welcome %s!", name) + content, err := renderTemplate("welcome", TemplateData{Name: name}) + if err != nil { + return err + } + return m.send(to, subject, content) +} + +func (m *SMTPMailer) SendInvite(to, name, inviterName string) error { + subject := fmt.Sprintf("%s invited you to join", inviterName) + content, err := renderTemplate("invite", TemplateData{Name: name, InviterName: inviterName}) + if err != nil { + return err + } + return m.send(to, subject, content) +} + +func (m *SMTPMailer) send(to, subject, body string) error { + addr := fmt.Sprintf("%s:%d", m.host, m.port) + auth := smtp.PlainAuth("", m.username, m.password, m.host) + + msg := fmt.Sprintf("From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s", + m.fromName, m.from, to, subject, body) + + if !m.useTLS { + return smtp.SendMail(addr, auth, m.from, []string{to}, []byte(msg)) + } + + if m.port == 465 { + return m.sendWithImplicitTLS(addr, auth, to, msg) + } + return m.sendWithSTARTTLS(addr, auth, to, msg) +} + +func (m *SMTPMailer) sendWithImplicitTLS(addr string, auth smtp.Auth, to, msg string) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: m.host}) + if err != nil { + return fmt.Errorf("smtp implicit TLS dial: %w", err) + } + client, err := smtp.NewClient(conn, m.host) + if err != nil { + return fmt.Errorf("smtp client: %w", err) + } + defer client.Close() + return m.deliver(client, auth, to, msg) +} + +func (m *SMTPMailer) sendWithSTARTTLS(addr string, auth smtp.Auth, to, msg string) error { + conn, err := smtp.Dial(addr) + if err != nil { + return fmt.Errorf("smtp dial: %w", err) + } + defer conn.Close() + if err := conn.StartTLS(&tls.Config{ServerName: m.host}); err != nil { + return fmt.Errorf("smtp STARTTLS: %w", err) + } + return m.deliver(conn, auth, to, msg) +} + +func (m *SMTPMailer) deliver(client *smtp.Client, auth smtp.Auth, to, msg string) error { + if auth != nil { + if err := client.Auth(auth); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + } + if err := client.Mail(m.from); err != nil { + return fmt.Errorf("smtp MAIL FROM: %w", err) + } + if err := client.Rcpt(to); err != nil { + return fmt.Errorf("smtp RCPT TO: %w", err) + } + w, err := client.Data() + if err != nil { + return fmt.Errorf("smtp DATA: %w", err) + } + if _, err := w.Write([]byte(msg)); err != nil { + return fmt.Errorf("smtp write body: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("smtp close data: %w", err) + } + return client.Quit() +} diff --git a/internal/infrastructure/email/smtp_test.go b/internal/infrastructure/email/smtp_test.go new file mode 100644 index 0000000..a7ab79e --- /dev/null +++ b/internal/infrastructure/email/smtp_test.go @@ -0,0 +1,21 @@ +package email + +import "testing" + +func TestSMTPMailer(t *testing.T) { + mailer := NewSMTPMailer("localhost", 587, "", "", false, "test@example.com", "Test", "http://localhost:3000") + if mailer == nil { + t.Fatal("NewSMTPMailer returned nil") + } + // Note: We can't actually send emails in tests without a real SMTP server + // but we can verify the struct is properly initialized + if mailer.host != "localhost" { + t.Errorf("expected host 'localhost', got '%s'", mailer.host) + } + if mailer.port != 587 { + t.Errorf("expected port 587, got %d", mailer.port) + } + if mailer.frontendURL != "http://localhost:3000" { + t.Errorf("expected frontendURL 'http://localhost:3000', got '%s'", mailer.frontendURL) + } +} diff --git a/internal/infrastructure/email/templates/invite.html b/internal/infrastructure/email/templates/invite.html new file mode 100644 index 0000000..e052d3b --- /dev/null +++ b/internal/infrastructure/email/templates/invite.html @@ -0,0 +1,14 @@ + + + + +

You've Been Invited

+

Hello {{.Name}},

+

{{.InviterName}} has invited you to join our platform.

+

+ Accept Invitation +

+
+

This is an automated message, please do not reply.

+ + diff --git a/internal/infrastructure/email/templates/password_reset.html b/internal/infrastructure/email/templates/password_reset.html new file mode 100644 index 0000000..652407d --- /dev/null +++ b/internal/infrastructure/email/templates/password_reset.html @@ -0,0 +1,15 @@ + + + + +

Reset Your Password

+

Hello {{.Name}},

+

You requested a password reset. Click the button below to set a new password:

+

+ Reset Password +

+

If you didn't request this, please ignore this email.

+
+

This link will expire in 1 hour.

+ + diff --git a/internal/infrastructure/email/templates/verification.html b/internal/infrastructure/email/templates/verification.html new file mode 100644 index 0000000..48f9ab1 --- /dev/null +++ b/internal/infrastructure/email/templates/verification.html @@ -0,0 +1,15 @@ + + + + +

Verify Your Email

+

Hello {{.Name}},

+

Thanks for signing up! Please verify your email address by clicking the button below:

+

+ Verify Email +

+

If you didn't create an account, please ignore this email.

+
+

This link will expire in 24 hours.

+ + diff --git a/internal/infrastructure/email/templates/welcome.html b/internal/infrastructure/email/templates/welcome.html new file mode 100644 index 0000000..d746d80 --- /dev/null +++ b/internal/infrastructure/email/templates/welcome.html @@ -0,0 +1,12 @@ + + + + +

Welcome!

+

Hello {{.Name}},

+

Welcome to our platform! Your account is now active and ready to use.

+

If you have any questions, feel free to reach out to our support team.

+
+

This is an automated message, please do not reply.

+ + diff --git a/internal/infrastructure/logger/zap.go b/internal/infrastructure/logger/zap.go index 5495507..a9e14e4 100644 --- a/internal/infrastructure/logger/zap.go +++ b/internal/infrastructure/logger/zap.go @@ -6,6 +6,7 @@ import ( "github.com/IDTS-LAB/go-codebase/internal/core/domain" "github.com/IDTS-LAB/go-codebase/internal/shared/config" + "go.opentelemetry.io/otel/trace" "go.uber.org/fx" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -34,12 +35,16 @@ func NewZapLogger(cfg *config.Config) (domain.Logger, error) { } var zapCfg zap.Config - if cfg.Log.Format == "console" { + if cfg.App.Env == "development" { zapCfg = zap.NewDevelopmentConfig() } else { zapCfg = zap.NewProductionConfig() } + if cfg.Log.Format == "json" { + zapCfg.Encoding = "json" + } + zapCfg.Level = zap.NewAtomicLevelAt(level) zapCfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder zapCfg.EncoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder @@ -61,29 +66,44 @@ func NewZapLogger(cfg *config.Config) (domain.Logger, error) { } func (l *ZapLogger) Debug(ctx context.Context, msg string, fields ...domain.Field) { - l.logger.Debug(msg, toZapFields(fields)...) + l.logger.Debug(msg, appendTraceFields(ctx, toZapFields(fields))...) } func (l *ZapLogger) Info(ctx context.Context, msg string, fields ...domain.Field) { - l.logger.Info(msg, toZapFields(fields)...) + l.logger.Info(msg, appendTraceFields(ctx, toZapFields(fields))...) } func (l *ZapLogger) Warn(ctx context.Context, msg string, fields ...domain.Field) { - l.logger.Warn(msg, toZapFields(fields)...) + l.logger.Warn(msg, appendTraceFields(ctx, toZapFields(fields))...) } func (l *ZapLogger) Error(ctx context.Context, msg string, fields ...domain.Field) { - l.logger.Error(msg, toZapFields(fields)...) + l.logger.Error(msg, appendTraceFields(ctx, toZapFields(fields))...) } func (l *ZapLogger) Fatal(ctx context.Context, msg string, fields ...domain.Field) { - l.logger.Fatal(msg, toZapFields(fields)...) + l.logger.Fatal(msg, appendTraceFields(ctx, toZapFields(fields))...) } func (l *ZapLogger) With(fields ...domain.Field) domain.Logger { return &ZapLogger{logger: l.logger.With(toZapFields(fields)...)} } +func appendTraceFields(ctx context.Context, fields []zap.Field) []zap.Field { + span := trace.SpanFromContext(ctx) + if !span.IsRecording() { + return fields + } + spanContext := span.SpanContext() + if spanContext.HasTraceID() { + fields = append(fields, zap.String("trace_id", spanContext.TraceID().String())) + } + if spanContext.HasSpanID() { + fields = append(fields, zap.String("span_id", spanContext.SpanID().String())) + } + return fields +} + func toZapFields(fields []domain.Field) []zap.Field { zapFields := make([]zap.Field, len(fields)) for i, f := range fields { diff --git a/internal/infrastructure/messaging/debug.go b/internal/infrastructure/messaging/debug.go new file mode 100644 index 0000000..4f809ae --- /dev/null +++ b/internal/infrastructure/messaging/debug.go @@ -0,0 +1,89 @@ +package messaging + +import ( + "encoding/json" + "net/http" + "sync" + "time" +) + +type debugEntry struct { + Subject string `json:"subject"` + Timestamp int64 `json:"timestamp"` + Payload string `json:"payload"` +} + +type debugBuffer struct { + mu sync.RWMutex + buf []debugEntry + cap int + next int + full bool +} + +func newDebugBuffer(capacity int) *debugBuffer { + return &debugBuffer{ + buf: make([]debugEntry, capacity), + cap: capacity, + } +} + +func (b *debugBuffer) append(subject string, data []byte) { + b.mu.Lock() + defer b.mu.Unlock() + + payload := string(data) + if len(payload) > 1024 { + payload = payload[:1024] + } + + b.buf[b.next] = debugEntry{ + Subject: subject, + Timestamp: time.Now().UnixMilli(), + Payload: payload, + } + b.next = (b.next + 1) % b.cap + if b.next == 0 { + b.full = true + } +} + +func (b *debugBuffer) read() []debugEntry { + b.mu.RLock() + defer b.mu.RUnlock() + + var start, count int + if b.full { + count = b.cap + start = b.next + } else { + count = b.next + start = 0 + } + + entries := make([]debugEntry, count) + for i := 0; i < count; i++ { + idx := (start + i) % b.cap + entries[i] = b.buf[idx] + } + + for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { + entries[i], entries[j] = entries[j], entries[i] + } + + return entries +} + +type debugNATSHandler struct { + buffer *debugBuffer +} + +func (h *debugNATSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if h.buffer == nil { + http.NotFound(w, r) + return + } + entries := h.buffer.read() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} diff --git a/internal/infrastructure/messaging/debug_test.go b/internal/infrastructure/messaging/debug_test.go new file mode 100644 index 0000000..3a6d9e5 --- /dev/null +++ b/internal/infrastructure/messaging/debug_test.go @@ -0,0 +1,76 @@ +package messaging + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestDebugBuffer_AppendAndRead(t *testing.T) { + buf := newDebugBuffer(3) + buf.append("foo", []byte("hello")) + buf.append("bar", []byte("world")) + entries := buf.read() + + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries[0].Subject != "bar" { + t.Fatalf("expected newest first: bar, got %s", entries[0].Subject) + } + if entries[1].Subject != "foo" { + t.Fatalf("expected second: foo, got %s", entries[1].Subject) + } +} + +func TestDebugBuffer_Capacity(t *testing.T) { + buf := newDebugBuffer(2) + buf.append("a", []byte("1")) + buf.append("b", []byte("2")) + buf.append("c", []byte("3")) + + entries := buf.read() + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries[0].Subject != "c" { + t.Fatalf("expected newest: c, got %s", entries[0].Subject) + } +} + +func TestDebugHandler_Enabled(t *testing.T) { + handler := &debugNATSHandler{buffer: newDebugBuffer(10)} + handler.buffer.append("test.subj", []byte(`{"msg":"hello"}`)) + + req := httptest.NewRequest("GET", "/debug/nats", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp []debugEntry + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp) != 1 { + t.Fatalf("expected 1 entry, got %d", len(resp)) + } + if resp[0].Subject != "test.subj" { + t.Fatalf("expected test.subj, got %s", resp[0].Subject) + } +} + +func TestDebugHandler_NotEnabled(t *testing.T) { + handler := &debugNATSHandler{buffer: nil} + + req := httptest.NewRequest("GET", "/debug/nats", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} diff --git a/internal/infrastructure/messaging/nats.go b/internal/infrastructure/messaging/nats.go index b33cfd6..717fff8 100644 --- a/internal/infrastructure/messaging/nats.go +++ b/internal/infrastructure/messaging/nats.go @@ -3,36 +3,96 @@ package messaging import ( "context" "fmt" + "net/http" "github.com/IDTS-LAB/go-codebase/internal/core/domain" "github.com/IDTS-LAB/go-codebase/internal/shared/config" "github.com/nats-io/nats.go" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" "go.uber.org/fx" ) -var Module = fx.Module("nats", fx.Provide(NewNATSMessenger)) +var Module = fx.Module("nats", + fx.Provide( + NewNATSMessenger, + fx.Annotate( + func(m *NATSMessenger) domain.Messenger { return m }, + fx.As(new(domain.Messenger)), + ), + func(m *NATSMessenger) nats.JetStreamContext { return m.JetStream() }, + ), +) + +var ( + natsPublishedTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "nats_published_total", + Help: "Total number of NATS messages published", + }, []string{"subject"}) + + natsReceivedTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "nats_received_total", + Help: "Total number of NATS messages received", + }, []string{"subject"}) + + natsPublishBytesTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "nats_publish_bytes_total", + Help: "Total bytes published to NATS", + }, []string{"subject"}) + + natsReceivedBytesTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "nats_received_bytes_total", + Help: "Total bytes received from NATS", + }, []string{"subject"}) +) type NATSMessenger struct { - conn *nats.Conn + conn *nats.Conn + js nats.JetStreamContext + debugBuf *debugBuffer } -func NewNATSMessenger(cfg *config.Config) (domain.Messenger, error) { +func NewNATSMessenger(cfg *config.Config) (*NATSMessenger, error) { + m := &NATSMessenger{} + if cfg.NATS.DebugEndpoint { + m.debugBuf = newDebugBuffer(100) + } if cfg.NATS.URL == "" { - return &NATSMessenger{}, nil + return m, nil } conn, err := nats.Connect(cfg.NATS.URL) if err != nil { return nil, fmt.Errorf("connect nats: %w", err) } + m.conn = conn - return &NATSMessenger{conn: conn}, nil + js, err := conn.JetStream() + if err != nil { + return nil, fmt.Errorf("jetstream: %w", err) + } + m.js = js + + return m, nil +} + +func (n *NATSMessenger) JetStream() nats.JetStreamContext { + return n.js +} + +func (n *NATSMessenger) DebugHandler() http.Handler { + return &debugNATSHandler{buffer: n.debugBuf} } func (n *NATSMessenger) Publish(ctx context.Context, subject string, data []byte) error { if n.conn == nil { return nil } + if n.debugBuf != nil { + n.debugBuf.append(subject, data) + } + natsPublishedTotal.WithLabelValues(subject).Inc() + natsPublishBytesTotal.WithLabelValues(subject).Add(float64(len(data))) return n.conn.Publish(subject, data) } @@ -41,6 +101,8 @@ func (n *NATSMessenger) Subscribe(ctx context.Context, subject string, handler f return nil } _, err := n.conn.Subscribe(subject, func(msg *nats.Msg) { + natsReceivedTotal.WithLabelValues(subject).Inc() + natsReceivedBytesTotal.WithLabelValues(subject).Add(float64(len(msg.Data))) handler(msg.Data) }) return err diff --git a/internal/monitoring/domain/metrics.go b/internal/monitoring/domain/metrics.go new file mode 100644 index 0000000..50a4d14 --- /dev/null +++ b/internal/monitoring/domain/metrics.go @@ -0,0 +1,9 @@ +package domain + +import "context" + +type MetricsRecorder interface { + IncrementCounter(ctx context.Context, name string, labels ...string) + ObserveHistogram(ctx context.Context, name string, value float64, labels ...string) + SetGauge(ctx context.Context, name string, value float64, labels ...string) +} diff --git a/internal/monitoring/infrastructure/prometheus/metrics.go b/internal/monitoring/infrastructure/prometheus/metrics.go new file mode 100644 index 0000000..8e77d27 --- /dev/null +++ b/internal/monitoring/infrastructure/prometheus/metrics.go @@ -0,0 +1,117 @@ +package prometheus + +import ( + "context" + "sync" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +type Recorder struct { + mu sync.RWMutex + counters map[string]*prometheus.CounterVec + histograms map[string]*prometheus.HistogramVec + gauges map[string]*prometheus.GaugeVec +} + +func NewRecorder() *Recorder { + return &Recorder{ + counters: make(map[string]*prometheus.CounterVec), + histograms: make(map[string]*prometheus.HistogramVec), + gauges: make(map[string]*prometheus.GaugeVec), + } +} + +func (r *Recorder) IncrementCounter(_ context.Context, name string, labels ...string) { + vec := r.getOrCreateCounter(name) + vec.WithLabelValues(extractValues(labels)...).Inc() +} + +func (r *Recorder) ObserveHistogram(_ context.Context, name string, value float64, labels ...string) { + vec := r.getOrCreateHistogram(name) + vec.WithLabelValues(extractValues(labels)...).Observe(value) +} + +func (r *Recorder) SetGauge(_ context.Context, name string, value float64, labels ...string) { + vec := r.getOrCreateGauge(name) + vec.WithLabelValues(extractValues(labels)...).Set(value) +} + +func extractValues(pairs []string) []string { + vals := make([]string, 0, len(pairs)/2) + for i := 1; i < len(pairs); i += 2 { + vals = append(vals, pairs[i]) + } + return vals +} + +func (r *Recorder) getOrCreateCounter(name string) *prometheus.CounterVec { + r.mu.RLock() + v, ok := r.counters[name] + r.mu.RUnlock() + if ok { + return v + } + + r.mu.Lock() + defer r.mu.Unlock() + + if v, ok = r.counters[name]; ok { + return v + } + + v = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: name, + Help: name, + }, []string{"method", "path", "status"}) + r.counters[name] = v + return v +} + +func (r *Recorder) getOrCreateHistogram(name string) *prometheus.HistogramVec { + r.mu.RLock() + v, ok := r.histograms[name] + r.mu.RUnlock() + if ok { + return v + } + + r.mu.Lock() + defer r.mu.Unlock() + + if v, ok = r.histograms[name]; ok { + return v + } + + v = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: name, + Help: name, + Buckets: prometheus.DefBuckets, + }, []string{"method", "path", "status"}) + r.histograms[name] = v + return v +} + +func (r *Recorder) getOrCreateGauge(name string) *prometheus.GaugeVec { + r.mu.RLock() + v, ok := r.gauges[name] + r.mu.RUnlock() + if ok { + return v + } + + r.mu.Lock() + defer r.mu.Unlock() + + if v, ok = r.gauges[name]; ok { + return v + } + + v = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: name, + Help: name, + }, []string{"method", "path", "status"}) + r.gauges[name] = v + return v +} diff --git a/internal/monitoring/interfaces/http/handler.go b/internal/monitoring/interfaces/http/handler.go new file mode 100644 index 0000000..79d7c12 --- /dev/null +++ b/internal/monitoring/interfaces/http/handler.go @@ -0,0 +1,11 @@ +package http + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +func NewMetricsHandler() http.Handler { + return promhttp.Handler() +} diff --git a/internal/monitoring/module.go b/internal/monitoring/module.go new file mode 100644 index 0000000..7042bc9 --- /dev/null +++ b/internal/monitoring/module.go @@ -0,0 +1,19 @@ +package monitoring + +import ( + "github.com/IDTS-LAB/go-codebase/internal/monitoring/domain" + "github.com/IDTS-LAB/go-codebase/internal/monitoring/infrastructure/prometheus" + metricsHttp "github.com/IDTS-LAB/go-codebase/internal/monitoring/interfaces/http" + "go.uber.org/fx" +) + +var Module = fx.Module("monitoring", + fx.Provide( + prometheus.NewRecorder, + fx.Annotate( + func(r *prometheus.Recorder) domain.MetricsRecorder { return r }, + fx.As(new(domain.MetricsRecorder)), + ), + metricsHttp.NewMetricsHandler, + ), +) diff --git a/internal/shared/auditlog/repository.go b/internal/shared/auditlog/repository.go index 1676c2c..7f2c69a 100644 --- a/internal/shared/auditlog/repository.go +++ b/internal/shared/auditlog/repository.go @@ -4,42 +4,47 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "time" + + "github.com/google/uuid" ) type AuditLog struct { - ID string `json:"id"` - RequestID string `json:"request_id"` - UserID *string `json:"user_id,omitempty"` - UserEmail *string `json:"user_email,omitempty"` - Method string `json:"method"` - Path string `json:"path"` - StatusCode int `json:"status_code"` - DurationMs int64 `json:"duration_ms"` - IP string `json:"ip"` - UserAgent string `json:"user_agent"` - RequestBody *string `json:"request_body,omitempty"` - ResponseSize int `json:"response_size"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + RequestID string `json:"request_id"` + UserID *string `json:"user_id,omitempty"` + UserEmail *string `json:"user_email,omitempty"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int `json:"status_code"` + DurationMs int64 `json:"duration_ms"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + RequestBody *string `json:"request_body,omitempty"` + ResponseSize int `json:"response_size"` + TenantID string `json:"tenant_id,omitempty"` + CreatedAt time.Time `json:"created_at"` } type ErrorLog struct { - ID string `json:"id"` - RequestID string `json:"request_id"` - UserID *string `json:"user_id,omitempty"` - UserEmail *string `json:"user_email,omitempty"` - Level string `json:"level"` - Message string `json:"message"` - Error string `json:"error"` - StackTrace string `json:"stack_trace"` - Method string `json:"method"` - Path string `json:"path"` - StatusCode int `json:"status_code"` - IP string `json:"ip"` - UserAgent string `json:"user_agent"` - RequestBody *string `json:"request_body,omitempty"` - Metadata json.RawMessage `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + RequestID string `json:"request_id"` + UserID *string `json:"user_id,omitempty"` + UserEmail *string `json:"user_email,omitempty"` + Level string `json:"level"` + Message string `json:"message"` + Error string `json:"error"` + StackTrace string `json:"stack_trace"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int `json:"status_code"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + RequestBody *string `json:"request_body,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + TenantID string `json:"tenant_id,omitempty"` + CreatedAt time.Time `json:"created_at"` } type Repository struct { @@ -51,23 +56,80 @@ func NewRepository(db *sql.DB) *Repository { } func (r *Repository) InsertAuditLog(ctx context.Context, log *AuditLog) error { - _, err := r.db.ExecContext(ctx, - `INSERT INTO audit_logs (id, request_id, user_id, user_email, method, path, status_code, duration_ms, ip, user_agent, request_body, response_size, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, - log.ID, log.RequestID, log.UserID, log.UserEmail, log.Method, log.Path, - log.StatusCode, log.DurationMs, log.IP, log.UserAgent, log.RequestBody, - log.ResponseSize, log.CreatedAt, + id, err := uuid.Parse(log.ID) + if err != nil { + return err + } + _, err = r.db.ExecContext(ctx, + `INSERT INTO audit_logs (id, request_id, user_id, user_email, method, path, status_code, duration_ms, ip, user_agent, request_body, response_size, tenant_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + id, + log.RequestID, + ptrStringToNullUUID(log.UserID), + ptrStringToNullString(log.UserEmail), + log.Method, + log.Path, + int32(log.StatusCode), + log.DurationMs, + log.IP, + log.UserAgent, + ptrStringToNullString(log.RequestBody), + int32(log.ResponseSize), + log.TenantID, + log.CreatedAt, ) - return err + if err != nil { + return fmt.Errorf("insert audit log: %w", err) + } + return nil } func (r *Repository) InsertErrorLog(ctx context.Context, log *ErrorLog) error { - _, err := r.db.ExecContext(ctx, - `INSERT INTO error_logs (id, request_id, user_id, user_email, level, message, error, stack_trace, method, path, status_code, ip, user_agent, request_body, metadata, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, - log.ID, log.RequestID, log.UserID, log.UserEmail, log.Level, log.Message, - log.Error, log.StackTrace, log.Method, log.Path, log.StatusCode, - log.IP, log.UserAgent, log.RequestBody, log.Metadata, log.CreatedAt, + id, err := uuid.Parse(log.ID) + if err != nil { + return err + } + _, err = r.db.ExecContext(ctx, + `INSERT INTO error_logs (id, request_id, user_id, user_email, level, message, error, stack_trace, method, path, status_code, ip, user_agent, request_body, metadata, tenant_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb, $16, $17)`, + id, + log.RequestID, + ptrStringToNullUUID(log.UserID), + ptrStringToNullString(log.UserEmail), + log.Level, + log.Message, + log.Error, + log.StackTrace, + log.Method, + log.Path, + int32(log.StatusCode), + log.IP, + log.UserAgent, + ptrStringToNullString(log.RequestBody), + log.Metadata, + log.TenantID, + log.CreatedAt, ) - return err + if err != nil { + return fmt.Errorf("insert error log: %w", err) + } + return nil +} + +func ptrStringToNullString(s *string) sql.NullString { + if s == nil { + return sql.NullString{Valid: false} + } + return sql.NullString{String: *s, Valid: true} +} + +func ptrStringToNullUUID(s *string) uuid.NullUUID { + if s == nil { + return uuid.NullUUID{Valid: false} + } + uid, err := uuid.Parse(*s) + if err != nil { + return uuid.NullUUID{Valid: false} + } + return uuid.NullUUID{UUID: uid, Valid: true} } diff --git a/internal/shared/config/config.go b/internal/shared/config/config.go index acc7af9..6bc8c44 100644 --- a/internal/shared/config/config.go +++ b/internal/shared/config/config.go @@ -6,7 +6,6 @@ import ( "strconv" "strings" - "github.com/joho/godotenv" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/file" @@ -18,120 +17,180 @@ var Module = fx.Module("config", fx.Provide(New)) // Config is the root configuration struct, grouped by usage. type Config struct { - App AppConfig - Server ServerConfig - Database DatabaseConfig - Redis RedisConfig - NATS NATSConfig - Auth AuthConfig - RateLimit RateLimitConfig - CORS CORSConfig - Idempotency IdempotencyConfig - Log LogConfig - Telemetry TelemetryConfig - Asynq AsynqConfig + App AppConfig `koanf:"app"` + Server ServerConfig `koanf:"server"` + Database DatabaseConfig `koanf:"database"` + Redis RedisConfig `koanf:"redis"` + NATS NATSConfig `koanf:"nats"` + Auth AuthConfig `koanf:"auth"` + RateLimit RateLimitConfig `koanf:"rate_limit"` + CORS CORSConfig `koanf:"cors"` + Idempotency IdempotencyConfig `koanf:"idempotency"` + Log LogConfig `koanf:"log"` + Telemetry TelemetryConfig `koanf:"telemetry"` + Asynq AsynqConfig `koanf:"asynq"` + Email EmailConfig `koanf:"email"` + Tenant TenantConfig `koanf:"multitenancy"` + Events EventsConfig `koanf:"events"` } // AppConfig holds application-level settings. type AppConfig struct { - Env string + Env string `koanf:"env"` } // ServerConfig holds HTTP server settings. type ServerConfig struct { - Port int - ReadTimeout int - WriteTimeout int - IdleTimeout int - MaxRequestBodySize int + Port int `koanf:"port"` + ReadTimeout int `koanf:"read_timeout"` + WriteTimeout int `koanf:"write_timeout"` + IdleTimeout int `koanf:"idle_timeout"` + MaxRequestBodySize int `koanf:"max_request_body_size"` } // DatabaseConfig holds PostgreSQL connection settings. type DatabaseConfig struct { - Host string - Port int - User string - Password string - Name string - SSLMode string - MaxOpenConns int - MaxIdleConns int - ConnMaxLifetime int + Host string `koanf:"host"` + Port int `koanf:"port"` + User string `koanf:"user"` + Password string `koanf:"password"` + Name string `koanf:"name"` + SSLMode string `koanf:"sslmode"` + MaxOpenConns int `koanf:"max_open_conns"` + MaxIdleConns int `koanf:"max_idle_conns"` + ConnMaxLifetime int `koanf:"conn_max_lifetime"` } // RedisConfig holds Redis connection settings. type RedisConfig struct { - Addr string - Password string - DB int - PoolSize int + Addr string `koanf:"addr"` + Password string `koanf:"password"` + DB int `koanf:"db"` + PoolSize int `koanf:"pool_size"` +} + +// EventsConfig holds event bus settings. +type EventsConfig struct { + Driver string `koanf:"driver"` +} + +// StreamConfig holds NATS JetStream stream settings. +type StreamConfig struct { + Name string `koanf:"name"` + Subjects []string `koanf:"subjects"` + Storage string `koanf:"storage"` + Retention string `koanf:"retention"` +} + +// ConsumerConfig holds NATS JetStream consumer settings. +type ConsumerConfig struct { + DurableName string `koanf:"durable_name"` + DeliverGroup string `koanf:"deliver_group"` + AckPolicy string `koanf:"ack_policy"` + MaxDeliver int `koanf:"max_deliver"` + AckWait int `koanf:"ack_wait"` } // NATSConfig holds NATS connection settings. type NATSConfig struct { - URL string + URL string `koanf:"url"` + DebugEndpoint bool `koanf:"debug_endpoint"` + Stream StreamConfig `koanf:"stream"` + Consumer ConsumerConfig `koanf:"consumer"` } // AuthConfig holds authentication and security settings. type AuthConfig struct { - JWTSecret string - JWTExpiration int - MaxLoginAttempts int - LockoutDuration int - TokenDenylist bool + JWTSecret string `koanf:"secret"` + JWTExpiration int `koanf:"expiration"` + MaxLoginAttempts int `koanf:"max_login_attempts"` + LockoutDuration int `koanf:"lockout_duration"` + TokenDenylist bool `koanf:"token_denylist"` } // RateLimitConfig holds rate limiting settings. type RateLimitConfig struct { - Requests int - Window int + Requests int `koanf:"requests"` + Window int `koanf:"window"` } // CORSConfig holds CORS settings. type CORSConfig struct { - AllowedOrigins []string - AllowedMethods []string - AllowedHeaders []string - AllowCredentials bool - MaxAge int + AllowedOrigins []string `koanf:"allowed_origins"` + AllowedMethods []string `koanf:"allowed_methods"` + AllowedHeaders []string `koanf:"allowed_headers"` + AllowCredentials bool `koanf:"allow_credentials"` + MaxAge int `koanf:"max_age"` } // IdempotencyConfig holds idempotency settings. type IdempotencyConfig struct { - Enabled bool - TTL int + Enabled bool `koanf:"enabled"` + TTL int `koanf:"ttl"` } // LogConfig holds logging settings. type LogConfig struct { - Level string - Format string + Level string `koanf:"level"` + Format string `koanf:"format"` } // TelemetryConfig holds OpenTelemetry settings. type TelemetryConfig struct { - ServiceName string - ExporterEndpoint string - SampleRate float64 + ServiceName string `koanf:"service_name"` + ExporterEndpoint string `koanf:"exporter_endpoint"` + SampleRate float64 `koanf:"sample_rate"` } // AsynqConfig holds background job settings. type AsynqConfig struct { - RedisAddr string + RedisAddr string `koanf:"redis_addr"` } -func New() (*Config, error) { - _ = godotenv.Load() +// TenantConfig holds multi-tenancy settings. +type TenantConfig struct { + Enabled bool `koanf:"enabled"` + TenantHeader string `koanf:"tenant_header"` + TenantJWTClaim string `koanf:"tenant_jwt_claim"` + Domain string `koanf:"domain"` +} +// EmailConfig holds email service settings. +type EmailConfig struct { + Provider string `koanf:"provider"` + From string `koanf:"from"` + FromName string `koanf:"from_name"` + FrontendURL string `koanf:"frontend_url"` + SMTP SMTPConfig `koanf:"smtp"` + SendGrid SendGridConfig `koanf:"sendgrid"` +} + +// SMTPConfig holds SMTP server settings. +type SMTPConfig struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + Username string `koanf:"username"` + Password string `koanf:"password"` + UseTLS bool `koanf:"use_tls"` +} + +// SendGridConfig holds SendGrid API settings. +type SendGridConfig struct { + APIKey string `koanf:"api_key"` +} + +func New() (*Config, error) { k := koanf.New(".") if err := k.Load(file.Provider("configs/config.yaml"), yaml.Parser()); err != nil { fmt.Printf("warning: could not load config.yaml: %v\n", err) } - k.Load(env.ProviderWithValue("", ".", func(s string, v string) (string, interface{}) { - return strings.Replace(strings.ToLower(s), "_", ".", -1), v - }), nil) + if err := k.Load(env.ProviderWithValue("", ".", func(s string, v string) (string, interface{}) { + return strings.ReplaceAll(strings.ToLower(s), "_", "."), v + }), nil); err != nil { + fmt.Printf("warning: could not load env config: %v\n", err) + } cfg := &Config{} if err := k.Unmarshal("", cfg); err != nil { @@ -228,6 +287,71 @@ func setDefaults(cfg *Config) { if cfg.Telemetry.SampleRate == 0 { cfg.Telemetry.SampleRate = 1.0 } + + // Email + if cfg.Email.Provider == "" { + cfg.Email.Provider = "console" + } + if cfg.Email.From == "" { + cfg.Email.From = "no-reply@example.com" + } + if cfg.Email.FromName == "" { + cfg.Email.FromName = "App" + } + if cfg.Email.FrontendURL == "" { + cfg.Email.FrontendURL = "http://localhost:3000" + } + if cfg.Email.SMTP.Host == "" { + cfg.Email.SMTP.Host = "localhost" + } + if cfg.Email.SMTP.Port == 0 { + cfg.Email.SMTP.Port = 587 + } + if !cfg.Email.SMTP.UseTLS && cfg.Email.SMTP.Host == "localhost" { + cfg.Email.SMTP.UseTLS = true + } + + // Events + if cfg.Events.Driver == "" { + cfg.Events.Driver = "memory" + } + + // NATS Stream/Consumer + if cfg.NATS.Stream.Name == "" { + cfg.NATS.Stream.Name = "events" + } + if cfg.NATS.Stream.Storage == "" { + cfg.NATS.Stream.Storage = "file" + } + if cfg.NATS.Stream.Retention == "" { + cfg.NATS.Stream.Retention = "interest" + } + if cfg.NATS.Consumer.DurableName == "" { + cfg.NATS.Consumer.DurableName = "event-bus" + } + if cfg.NATS.Consumer.DeliverGroup == "" { + cfg.NATS.Consumer.DeliverGroup = "event-bus" + } + if cfg.NATS.Consumer.AckPolicy == "" { + cfg.NATS.Consumer.AckPolicy = "explicit" + } + if cfg.NATS.Consumer.MaxDeliver == 0 { + cfg.NATS.Consumer.MaxDeliver = -1 + } + if cfg.NATS.Consumer.AckWait == 0 { + cfg.NATS.Consumer.AckWait = 30 + } + + // Tenant + if cfg.Tenant.TenantHeader == "" { + cfg.Tenant.TenantHeader = "X-Tenant-ID" + } + if cfg.Tenant.TenantJWTClaim == "" { + cfg.Tenant.TenantJWTClaim = "tenant_id" + } + if cfg.Tenant.Domain == "" { + cfg.Tenant.Domain = "app.com" + } } func applyEnvOverrides(cfg *Config) { @@ -322,6 +446,9 @@ func applyEnvOverrides(cfg *Config) { if v := os.Getenv("NATS_URL"); v != "" { cfg.NATS.URL = v } + if v := os.Getenv("NATS_DEBUG_ENDPOINT"); v != "" { + cfg.NATS.DebugEndpoint = v == "true" || v == "1" + } // Auth if v := os.Getenv("JWT_SECRET"); v != "" { @@ -401,4 +528,49 @@ func applyEnvOverrides(cfg *Config) { cfg.Telemetry.SampleRate = f } } + + // Email + if v := os.Getenv("EMAIL_PROVIDER"); v != "" { + cfg.Email.Provider = v + } + if v := os.Getenv("EMAIL_FROM"); v != "" { + cfg.Email.From = v + } + if v := os.Getenv("EMAIL_FROM_NAME"); v != "" { + cfg.Email.FromName = v + } + if v := os.Getenv("FRONTEND_URL"); v != "" { + cfg.Email.FrontendURL = v + } + if v := os.Getenv("SMTP_HOST"); v != "" { + cfg.Email.SMTP.Host = v + } + if v := os.Getenv("SMTP_PORT"); v != "" { + if port, err := strconv.Atoi(v); err == nil { + cfg.Email.SMTP.Port = port + } + } + if v := os.Getenv("SMTP_USERNAME"); v != "" { + cfg.Email.SMTP.Username = v + } + if v := os.Getenv("SMTP_PASSWORD"); v != "" { + cfg.Email.SMTP.Password = v + } + if v := os.Getenv("SMTP_USE_TLS"); v != "" { + cfg.Email.SMTP.UseTLS = v == "true" || v == "1" + } + if v := os.Getenv("SENDGRID_API_KEY"); v != "" { + cfg.Email.SendGrid.APIKey = v + } + + // Tenant + if v := os.Getenv("MULTITENANCY_ENABLED"); v != "" { + cfg.Tenant.Enabled = v == "true" || v == "1" + } + if v := os.Getenv("MULTITENANCY_TENANT_HEADER"); v != "" { + cfg.Tenant.TenantHeader = v + } + if v := os.Getenv("MULTITENANCY_DOMAIN"); v != "" { + cfg.Tenant.Domain = v + } } diff --git a/internal/shared/cqrs/bus.go b/internal/shared/cqrs/bus.go new file mode 100644 index 0000000..da993b1 --- /dev/null +++ b/internal/shared/cqrs/bus.go @@ -0,0 +1,80 @@ +package cqrs + +import ( + "context" + "fmt" + "reflect" + "sync" +) + +type CommandHandler interface { + Handle(ctx context.Context, cmd any) (any, error) +} + +type CommandBus interface { + Dispatch(ctx context.Context, cmd any) (any, error) + Register(cmd any, handler CommandHandler) +} + +type QueryHandler interface { + Handle(ctx context.Context, query any) (any, error) +} + +type QueryBus interface { + Ask(ctx context.Context, query any) (any, error) + Register(query any, handler QueryHandler) +} + +type inMemoryCommandBus struct { + mu sync.RWMutex + handlers map[string]CommandHandler +} + +func NewInMemoryCommandBus() *inMemoryCommandBus { + return &inMemoryCommandBus{handlers: make(map[string]CommandHandler)} +} + +func (b *inMemoryCommandBus) Register(cmd any, handler CommandHandler) { + key := reflect.TypeOf(cmd).String() + b.mu.Lock() + defer b.mu.Unlock() + b.handlers[key] = handler +} + +func (b *inMemoryCommandBus) Dispatch(ctx context.Context, cmd any) (any, error) { + key := reflect.TypeOf(cmd).String() + b.mu.RLock() + handler, ok := b.handlers[key] + b.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("no handler registered for command: %s", key) + } + return handler.Handle(ctx, cmd) +} + +type inMemoryQueryBus struct { + mu sync.RWMutex + handlers map[string]QueryHandler +} + +func NewInMemoryQueryBus() *inMemoryQueryBus { + return &inMemoryQueryBus{handlers: make(map[string]QueryHandler)} +} + +func (b *inMemoryQueryBus) Register(query any, handler QueryHandler) { + key := reflect.TypeOf(query).String() + b.mu.Lock() + defer b.mu.Unlock() + b.handlers[key] = handler +} + +func (b *inMemoryQueryBus) Ask(ctx context.Context, query any) (any, error) { + key := reflect.TypeOf(query).String() + b.mu.RLock() + handler, ok := b.handlers[key] + b.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("no handler registered for query: %s", key) + } + return handler.Handle(ctx, query) +} diff --git a/internal/shared/cqrs/bus_test.go b/internal/shared/cqrs/bus_test.go new file mode 100644 index 0000000..ad1b5a8 --- /dev/null +++ b/internal/shared/cqrs/bus_test.go @@ -0,0 +1,84 @@ +package cqrs + +import ( + "context" + "errors" + "testing" +) + +type testCommand struct { + Value string +} + +type testCommandHandler struct{} + +func (h *testCommandHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(testCommand) + if c.Value == "error" { + return nil, errors.New("test error") + } + return "handled:" + c.Value, nil +} + +type testQuery struct { + ID string +} + +type testQueryHandler struct{} + +func (h *testQueryHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(testQuery) + return "result:" + q.ID, nil +} + +func TestCommandBus_Dispatch(t *testing.T) { + bus := NewInMemoryCommandBus() + bus.Register(testCommand{}, &testCommandHandler{}) + + resp, err := bus.Dispatch(context.Background(), testCommand{Value: "hello"}) + if err != nil { + t.Fatal(err) + } + if resp.(string) != "handled:hello" { + t.Fatalf("expected 'handled:hello', got '%s'", resp) + } +} + +func TestCommandBus_Unregistered(t *testing.T) { + bus := NewInMemoryCommandBus() + _, err := bus.Dispatch(context.Background(), testCommand{Value: "x"}) + if err == nil { + t.Fatal("expected error for unregistered command") + } +} + +func TestCommandBus_HandlerError(t *testing.T) { + bus := NewInMemoryCommandBus() + bus.Register(testCommand{}, &testCommandHandler{}) + + _, err := bus.Dispatch(context.Background(), testCommand{Value: "error"}) + if err == nil || err.Error() != "test error" { + t.Fatalf("expected 'test error', got '%v'", err) + } +} + +func TestQueryBus_Ask(t *testing.T) { + bus := NewInMemoryQueryBus() + bus.Register(testQuery{}, &testQueryHandler{}) + + resp, err := bus.Ask(context.Background(), testQuery{ID: "123"}) + if err != nil { + t.Fatal(err) + } + if resp.(string) != "result:123" { + t.Fatalf("expected 'result:123', got '%s'", resp) + } +} + +func TestQueryBus_Unregistered(t *testing.T) { + bus := NewInMemoryQueryBus() + _, err := bus.Ask(context.Background(), testQuery{ID: "x"}) + if err == nil { + t.Fatal("expected error for unregistered query") + } +} diff --git a/internal/shared/cqrs/module.go b/internal/shared/cqrs/module.go new file mode 100644 index 0000000..df9fcb2 --- /dev/null +++ b/internal/shared/cqrs/module.go @@ -0,0 +1,17 @@ +package cqrs + +import "go.uber.org/fx" + +var Module = fx.Module("cqrs", + fx.Provide( + fx.Annotate( + NewInMemoryCommandBus, + fx.As(new(CommandBus)), + ), + + fx.Annotate( + NewInMemoryQueryBus, + fx.As(new(QueryBus)), + ), + ), +) diff --git a/internal/shared/cursor/cursor.go b/internal/shared/cursor/cursor.go new file mode 100644 index 0000000..67a21fd --- /dev/null +++ b/internal/shared/cursor/cursor.go @@ -0,0 +1,36 @@ +package cursor + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" +) + +type Cursor struct { + Timestamp time.Time `json:"t"` + ID uuid.UUID `json:"i"` +} + +func Encode(t time.Time, id uuid.UUID) string { + c := Cursor{Timestamp: t.UTC(), ID: id} + b, _ := json.Marshal(c) + return base64.URLEncoding.EncodeToString(b) +} + +func Decode(s string) (Cursor, error) { + if s == "" { + return Cursor{}, fmt.Errorf("empty cursor") + } + b, err := base64.URLEncoding.DecodeString(s) + if err != nil { + return Cursor{}, fmt.Errorf("decode cursor: %w", err) + } + var c Cursor + if err := json.Unmarshal(b, &c); err != nil { + return Cursor{}, fmt.Errorf("unmarshal cursor: %w", err) + } + return c, nil +} diff --git a/internal/shared/cursor/cursor_test.go b/internal/shared/cursor/cursor_test.go new file mode 100644 index 0000000..a7a81f3 --- /dev/null +++ b/internal/shared/cursor/cursor_test.go @@ -0,0 +1,42 @@ +package cursor + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +func TestEncodeDecode(t *testing.T) { + now := time.Now().UTC().Truncate(time.Microsecond) + id := uuid.New() + + token := Encode(now, id) + if token == "" { + t.Fatal("expected non-empty token") + } + + c, err := Decode(token) + if err != nil { + t.Fatalf("decode error: %v", err) + } + + if !c.Timestamp.Equal(now) { + t.Errorf("timestamp mismatch: got %v, want %v", c.Timestamp, now) + } + if c.ID != id { + t.Errorf("id mismatch: got %v, want %v", c.ID, id) + } +} + +func TestDecodeInvalid(t *testing.T) { + _, err := Decode("invalid-base64!") + if err == nil { + t.Fatal("expected error for invalid token") + } + + _, err = Decode("") + if err == nil { + t.Fatal("expected error for empty token") + } +} diff --git a/internal/shared/events/events.go b/internal/shared/events/events.go index 521ad90..de7bf92 100644 --- a/internal/shared/events/events.go +++ b/internal/shared/events/events.go @@ -12,24 +12,32 @@ type Event struct { type Handler func(ctx context.Context, event Event) error -type EventBus struct { +// EventBus defines the contract for publishing and subscribing to events. +type EventBus interface { + Publish(ctx context.Context, event Event) error + Subscribe(eventType string, handler Handler) +} + +// InMemoryEventBus is a synchronous in-memory implementation of EventBus. +type InMemoryEventBus struct { mu sync.RWMutex handlers map[string][]Handler } -func NewEventBus() *EventBus { - return &EventBus{ +// NewInMemoryEventBus creates a new InMemoryEventBus. +func NewInMemoryEventBus() *InMemoryEventBus { + return &InMemoryEventBus{ handlers: make(map[string][]Handler), } } -func (eb *EventBus) Subscribe(eventType string, handler Handler) { +func (eb *InMemoryEventBus) Subscribe(eventType string, handler Handler) { eb.mu.Lock() defer eb.mu.Unlock() eb.handlers[eventType] = append(eb.handlers[eventType], handler) } -func (eb *EventBus) Publish(ctx context.Context, event Event) error { +func (eb *InMemoryEventBus) Publish(ctx context.Context, event Event) error { eb.mu.RLock() handlers := eb.handlers[event.Type] eb.mu.RUnlock() diff --git a/internal/shared/events/logging_event_bus.go b/internal/shared/events/logging_event_bus.go new file mode 100644 index 0000000..95e8f43 --- /dev/null +++ b/internal/shared/events/logging_event_bus.go @@ -0,0 +1,42 @@ +package events + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// LoggingEventBus wraps an EventBus and logs any errors returned by Publish. +// It ensures event publish failures are never silently lost. +type LoggingEventBus struct { + inner EventBus + log domain.Logger +} + +// NewLoggingEventBus creates a new LoggingEventBus wrapping the provided EventBus. +func NewLoggingEventBus(inner EventBus, log domain.Logger) EventBus { + return &LoggingEventBus{inner: inner, log: log} +} + +// Publish delegates to the wrapped EventBus and logs any error. +func (b *LoggingEventBus) Publish(ctx context.Context, event Event) error { + err := b.inner.Publish(ctx, event) + if err != nil { + if span := trace.SpanFromContext(ctx); span.IsRecording() { + span.SetStatus(codes.Error, "event publish failed") + span.RecordError(err) + } + b.log.Error(ctx, "event publish failed", + domain.String("event_type", event.Type), + domain.Error(err), + ) + } + return err +} + +// Subscribe delegates to the wrapped EventBus. +func (b *LoggingEventBus) Subscribe(eventType string, handler Handler) { + b.inner.Subscribe(eventType, handler) +} diff --git a/internal/shared/events/module.go b/internal/shared/events/module.go new file mode 100644 index 0000000..ae3f1a6 --- /dev/null +++ b/internal/shared/events/module.go @@ -0,0 +1,35 @@ +package events + +import ( + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/config" + "github.com/nats-io/nats.go" + "go.uber.org/fx" +) + +func ensureStream(js nats.JetStreamContext, cfg config.StreamConfig) { + _, err := js.AddStream(&nats.StreamConfig{ + Name: cfg.Name, + Subjects: cfg.Subjects, + Storage: nats.FileStorage, + Retention: nats.InterestPolicy, + }) + if err != nil && err != nats.ErrStreamNameAlreadyInUse { + // log but don't fatal + } +} + +func provideEventBus(cfg *config.Config, js nats.JetStreamContext, log domain.Logger) EventBus { + var bus EventBus + if cfg.Events.Driver == "nats" { + ensureStream(js, cfg.NATS.Stream) + bus = NewNATSEventBus(&jsContextAdapter{js: js}) + } else { + bus = NewInMemoryEventBus() + } + return NewLoggingEventBus(bus, log) +} + +var Module = fx.Module("events", + fx.Provide(provideEventBus), +) diff --git a/internal/shared/events/nats_event_bus.go b/internal/shared/events/nats_event_bus.go new file mode 100644 index 0000000..ee0213c --- /dev/null +++ b/internal/shared/events/nats_event_bus.go @@ -0,0 +1,104 @@ +package events + +import ( + "context" + "encoding/json" + "sync" + + "github.com/nats-io/nats.go" +) + +type jetStreamMsg interface { + Ack() error + Nak() error + Data() []byte +} + +type jetStreamer interface { + Publish(subject string, data []byte) error + Subscribe(subject string, cb func(msg jetStreamMsg)) error +} + +type jsMsgAdapter struct { + msg *nats.Msg +} + +func (a *jsMsgAdapter) Ack() error { return a.msg.Ack() } +func (a *jsMsgAdapter) Nak() error { return a.msg.Nak() } +func (a *jsMsgAdapter) Data() []byte { return a.msg.Data } + +type jsContextAdapter struct { + js nats.JetStreamContext +} + +func (a *jsContextAdapter) Publish(subject string, data []byte) error { + _, err := a.js.Publish(subject, data) + return err +} + +func (a *jsContextAdapter) Subscribe(subject string, cb func(msg jetStreamMsg)) error { + _, err := a.js.QueueSubscribe(subject, "event-bus", func(msg *nats.Msg) { + cb(&jsMsgAdapter{msg: msg}) + }, nats.Durable("event-bus"), nats.MaxDeliver(-1), nats.AckWait(30*1e9), nats.ManualAck()) + return err +} + +type NATSEventBus struct { + js jetStreamer + mu sync.RWMutex + handlers map[string][]Handler +} + +func NewNATSEventBus(js jetStreamer) *NATSEventBus { + return &NATSEventBus{ + js: js, + handlers: make(map[string][]Handler), + } +} + +func (b *NATSEventBus) Publish(ctx context.Context, event Event) error { + data, err := json.Marshal(event.Payload) + if err != nil { + return err + } + subject := "events." + event.Type + return b.js.Publish(subject, data) +} + +func (b *NATSEventBus) Subscribe(eventType string, handler Handler) { + b.mu.Lock() + defer b.mu.Unlock() + + if len(b.handlers[eventType]) == 0 { + b.startSubscription(eventType) + } + b.handlers[eventType] = append(b.handlers[eventType], handler) +} + +func (b *NATSEventBus) startSubscription(eventType string) { + subject := "events." + eventType + _ = b.js.Subscribe(subject, func(msg jetStreamMsg) { + payload := CreatePayload(eventType) + if payload == nil { + msg.Ack() + return + } + if err := json.Unmarshal(msg.Data(), payload); err != nil { + msg.Ack() + return + } + event := Event{Type: eventType, Payload: payload} + + b.mu.RLock() + handlers := b.handlers[eventType] + b.mu.RUnlock() + + for _, h := range handlers { + if err := h(context.Background(), event); err != nil { + msg.Nak() + return + } + } + msg.Ack() + }) +} diff --git a/internal/shared/events/nats_event_bus_test.go b/internal/shared/events/nats_event_bus_test.go new file mode 100644 index 0000000..98013cd --- /dev/null +++ b/internal/shared/events/nats_event_bus_test.go @@ -0,0 +1,116 @@ +package events + +import ( + "context" + "errors" + "sync" + "testing" +) + +type mockJetStream struct { + mu sync.Mutex + published []struct{ subject string; data []byte } + subscribed map[string]func(msg jetStreamMsg) + lastMsg *testMsg +} + +func (m *mockJetStream) Publish(subject string, data []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + m.published = append(m.published, struct{ subject string; data []byte }{subject, data}) + return nil +} + +func (m *mockJetStream) Subscribe(subject string, cb func(msg jetStreamMsg)) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.subscribed == nil { + m.subscribed = make(map[string]func(msg jetStreamMsg)) + } + m.subscribed[subject] = cb + return nil +} + +func (m *mockJetStream) deliver(subject string, data []byte) { + m.mu.Lock() + cb := m.subscribed[subject] + m.mu.Unlock() + if cb != nil { + msg := &testMsg{subj: subject, dat: data} + m.lastMsg = msg + cb(msg) + } +} + +type testMsg struct { + subj string + dat []byte + acked bool + naked bool +} + +func (m *testMsg) Ack() error { m.acked = true; return nil } +func (m *testMsg) Nak() error { m.naked = true; return nil } +func (m *testMsg) Data() []byte { return m.dat } + +func TestNATSEventBus_Publish(t *testing.T) { + mock := &mockJetStream{} + bus := NewNATSEventBus(mock) + + ctx := context.Background() + err := bus.Publish(ctx, Event{ + Type: "nats.test.event", + Payload: &testPayload{Message: "hello"}, + }) + if err != nil { + t.Fatalf("publish: %v", err) + } + + mock.mu.Lock() + published := len(mock.published) + mock.mu.Unlock() + + if published != 1 { + t.Fatalf("expected 1 publish, got %d", published) + } +} + +func TestNATSEventBus_SubscribeAcksOnSuccess(t *testing.T) { + Register("nats.test.event2", func() interface{} { return &testPayload{} }) + + mock := &mockJetStream{} + bus := NewNATSEventBus(mock) + + bus.Subscribe("nats.test.event2", func(_ context.Context, event Event) error { + return nil + }) + + mock.deliver("events.nats.test.event2", []byte(`{"message":"world"}`)) + + if !mock.lastMsg.acked { + t.Fatal("expected Ack on successful handler") + } + if mock.lastMsg.naked { + t.Fatal("expected no Nak on successful handler") + } +} + +func TestNATSEventBus_SubscribeNaksOnError(t *testing.T) { + Register("nats.test.event3", func() interface{} { return &testPayload{} }) + + mock := &mockJetStream{} + bus := NewNATSEventBus(mock) + + bus.Subscribe("nats.test.event3", func(_ context.Context, event Event) error { + return errors.New("handler error") + }) + + mock.deliver("events.nats.test.event3", []byte(`{"message":"fail"}`)) + + if !mock.lastMsg.naked { + t.Fatal("expected Nak on handler error") + } + if mock.lastMsg.acked { + t.Fatal("expected no Ack on handler error") + } +} diff --git a/internal/shared/events/registry.go b/internal/shared/events/registry.go new file mode 100644 index 0000000..f0d8689 --- /dev/null +++ b/internal/shared/events/registry.go @@ -0,0 +1,15 @@ +package events + +var registry = map[string]func() interface{}{} + +func Register(eventType string, factory func() interface{}) { + registry[eventType] = factory +} + +func CreatePayload(eventType string) interface{} { + factory, ok := registry[eventType] + if !ok { + return nil + } + return factory() +} diff --git a/internal/shared/events/registry_test.go b/internal/shared/events/registry_test.go new file mode 100644 index 0000000..843061a --- /dev/null +++ b/internal/shared/events/registry_test.go @@ -0,0 +1,28 @@ +package events + +import "testing" + +type testPayload struct { + Message string `json:"message"` +} + +func init() { + Register("test.event", func() interface{} { return &testPayload{} }) +} + +func TestRegistry_RegisterAndCreate(t *testing.T) { + p := CreatePayload("test.event") + if p == nil { + t.Fatal("expected non-nil payload") + } + if _, ok := p.(*testPayload); !ok { + t.Fatal("expected *testPayload type") + } +} + +func TestRegistry_UnknownType(t *testing.T) { + p := CreatePayload("unknown.event") + if p != nil { + t.Fatal("expected nil for unknown type") + } +} diff --git a/internal/shared/httpadapter/adapter.go b/internal/shared/httpadapter/adapter.go new file mode 100644 index 0000000..429ef25 --- /dev/null +++ b/internal/shared/httpadapter/adapter.go @@ -0,0 +1,36 @@ +package httpadapter + +import ( + "context" + "net/http" + + "github.com/IDTS-LAB/go-codebase/internal/shared/utils" +) + +func Adapt[T any](fn func(ctx context.Context, r *http.Request) (T, error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + data, err := fn(r.Context(), r) + utils.Handle(w, r, data, err) + } +} + +func AdaptCreated[T any](fn func(ctx context.Context, r *http.Request) (T, error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + data, err := fn(r.Context(), r) + utils.HandleCreated(w, r, data, err) + } +} + +func AdaptNoContent(fn func(ctx context.Context, r *http.Request) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + err := fn(r.Context(), r) + utils.HandleNoContent(w, r, err) + } +} + +func AdaptPaginated[T any](fn func(ctx context.Context, r *http.Request) (utils.PaginatedResult[T], error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + result, err := fn(r.Context(), r) + utils.HandlePaginated(w, r, result.Data, result.Page, result.PerPage, result.Total, err) + } +} diff --git a/internal/shared/httpadapter/adapter_test.go b/internal/shared/httpadapter/adapter_test.go new file mode 100644 index 0000000..1fe3d76 --- /dev/null +++ b/internal/shared/httpadapter/adapter_test.go @@ -0,0 +1,98 @@ +package httpadapter + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/utils" + "github.com/stretchr/testify/assert" +) + +func TestAdapt_ReturnsSuccessEnvelope(t *testing.T) { + handler := Adapt(func(ctx context.Context, r *http.Request) (map[string]string, error) { + return map[string]string{"id": "1"}, nil + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + var resp utils.APIResponse + assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.True(t, resp.Success) +} + +func TestAdaptCreated_Returns201(t *testing.T) { + handler := AdaptCreated(func(ctx context.Context, r *http.Request) (map[string]string, error) { + return map[string]string{"id": "1"}, nil + }) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusCreated, rec.Code) + var resp utils.APIResponse + assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.True(t, resp.Success) +} + +func TestAdaptNoContent_ReturnsSuccessWithNilData(t *testing.T) { + handler := AdaptNoContent(func(ctx context.Context, r *http.Request) error { + return nil + }) + + req := httptest.NewRequest(http.MethodDelete, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + var resp utils.APIResponse + assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.True(t, resp.Success) + assert.Nil(t, resp.Data) +} + +func TestAdaptPaginated_ReturnsPaginationMeta(t *testing.T) { + handler := AdaptPaginated[map[string]string](func(ctx context.Context, r *http.Request) (utils.PaginatedResult[map[string]string], error) { + return utils.PaginatedResult[map[string]string]{ + Data: []map[string]string{{"id": "1"}}, + Page: 1, + PerPage: 20, + Total: 1, + }, nil + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + var resp utils.APIResponse + assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.True(t, resp.Success) + assert.NotNil(t, resp.Meta) + meta := resp.Meta.(map[string]interface{}) + assert.Equal(t, float64(1), meta["total_pages"]) +} + +func TestAdapt_MapsDomainError(t *testing.T) { + handler := Adapt(func(ctx context.Context, r *http.Request) (map[string]string, error) { + return nil, domain.ErrNotFound + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + var resp utils.APIResponse + assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.False(t, resp.Success) + assert.Equal(t, "NOT_FOUND", resp.Error.Code) +} diff --git a/internal/shared/middleware/audit.go b/internal/shared/middleware/audit.go index e3fb9da..eb46538 100644 --- a/internal/shared/middleware/audit.go +++ b/internal/shared/middleware/audit.go @@ -32,16 +32,17 @@ func AuditLog(repo *auditlog.Repository) func(http.Handler) http.Handler { requestID := GetRequestID(r.Context()) entry := &auditlog.AuditLog{ - ID: uuid.New().String(), - RequestID: requestID, - Method: r.Method, - Path: r.URL.Path, - StatusCode: wrapped.statusCode, - DurationMs: duration.Milliseconds(), - IP: r.RemoteAddr, - UserAgent: r.UserAgent(), + ID: uuid.New().String(), + RequestID: requestID, + Method: r.Method, + Path: r.URL.Path, + StatusCode: wrapped.statusCode, + DurationMs: duration.Milliseconds(), + IP: r.RemoteAddr, + UserAgent: r.UserAgent(), ResponseSize: wrapped.bytesWritten, - CreatedAt: time.Now(), + TenantID: GetTenantID(r.Context()), + CreatedAt: time.Now(), } if userID != "" { diff --git a/internal/shared/middleware/formatter.go b/internal/shared/middleware/formatter.go new file mode 100644 index 0000000..380c494 --- /dev/null +++ b/internal/shared/middleware/formatter.go @@ -0,0 +1,126 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "net/http" + + "github.com/IDTS-LAB/go-codebase/internal/shared/utils" +) + +func ResponseFormatter() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fw := &formattingWriter{ResponseWriter: w, statusCode: http.StatusOK} + next.ServeHTTP(fw, r) + + if len(fw.body) == 0 { + w.WriteHeader(fw.statusCode) + return + } + + if isEnvelope(fw.body) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(fw.statusCode) + _, _ = w.Write(fw.body) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(fw.statusCode) + + if fw.statusCode >= 400 { + var errBody struct { + Code string `json:"code"` + Message string `json:"message"` + } + if json.Unmarshal(fw.body, &errBody) == nil && errBody.Message != "" { + _ = json.NewEncoder(w).Encode(utils.APIResponse{ + Success: false, + Error: &utils.ErrorBody{Code: errBody.Code, Message: errBody.Message}, + }) + return + } + _ = json.NewEncoder(w).Encode(utils.APIResponse{ + Success: false, + Error: &utils.ErrorBody{Code: http.StatusText(fw.statusCode), Message: string(bytes.TrimSpace(fw.body))}, + }) + return + } + + var paginated struct { + Data interface{} `json:"data"` + Pagination interface{} `json:"pagination"` + } + if json.Unmarshal(fw.body, &paginated) == nil && paginated.Data != nil && paginated.Pagination != nil { + var meta utils.PaginationMeta + metaBytes, _ := json.Marshal(paginated.Pagination) + _ = json.Unmarshal(metaBytes, &meta) + _ = json.NewEncoder(w).Encode(utils.APIResponse{ + Success: true, + Data: paginated.Data, + Meta: &meta, + }) + return + } + + var cursorResp struct { + Data interface{} `json:"data"` + Meta interface{} `json:"meta"` + } + if json.Unmarshal(fw.body, &cursorResp) == nil && cursorResp.Data != nil && cursorResp.Meta != nil { + var meta utils.CursorMeta + metaBytes, _ := json.Marshal(cursorResp.Meta) + _ = json.Unmarshal(metaBytes, &meta) + _ = json.NewEncoder(w).Encode(utils.APIResponse{ + Success: true, + Data: cursorResp.Data, + Meta: &meta, + }) + return + } + + var raw interface{} + _ = json.Unmarshal(fw.body, &raw) + _ = json.NewEncoder(w).Encode(utils.APIResponse{ + Success: true, + Data: raw, + Meta: nil, + }) + }) + } +} + +type formattingWriter struct { + http.ResponseWriter + statusCode int + body []byte + wroteHeader bool +} + +func (w *formattingWriter) WriteHeader(code int) { + if w.wroteHeader { + return + } + w.statusCode = code + w.wroteHeader = true +} + +func (w *formattingWriter) Write(b []byte) (int, error) { + w.body = append(w.body, b...) + return len(b), nil +} + +func (w *formattingWriter) Header() http.Header { + return w.ResponseWriter.Header() +} + +func isEnvelope(body []byte) bool { + var envelope struct { + Success *bool `json:"success"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return false + } + return envelope.Success != nil +} diff --git a/internal/shared/middleware/formatter_test.go b/internal/shared/middleware/formatter_test.go new file mode 100644 index 0000000..8839d97 --- /dev/null +++ b/internal/shared/middleware/formatter_test.go @@ -0,0 +1,98 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/IDTS-LAB/go-codebase/internal/shared/utils" + "github.com/stretchr/testify/assert" +) + +func TestResponseFormatter_WrapsRawSuccessJSON(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"id": "1", "name": "todo"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + ResponseFormatter()(handler).ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp utils.APIResponse + err := json.Unmarshal(rec.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.True(t, resp.Success) + assert.NotNil(t, resp.Data) + assert.Nil(t, resp.Error) +} + +func TestResponseFormatter_PassesThroughExistingEnvelope(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + utils.RespondSuccess(w, map[string]string{"id": "1"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + ResponseFormatter()(handler).ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp utils.APIResponse + err := json.Unmarshal(rec.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.True(t, resp.Success) + assert.NotNil(t, resp.Data) +} + +func TestResponseFormatter_WrapsRawErrorText(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + ResponseFormatter()(handler).ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + + var resp utils.APIResponse + err := json.Unmarshal(rec.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.False(t, resp.Success) + assert.NotNil(t, resp.Error) + assert.Equal(t, "Not Found", resp.Error.Code) + assert.Equal(t, "not found", resp.Error.Message) +} + +func TestResponseFormatter_UnwrapsPaginatedPayload(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + payload := utils.PaginatedPayload[map[string]string]{ + Data: []map[string]string{{"id": "1"}}, + Pagination: utils.PaginationMeta{ + Page: 1, + PerPage: 20, + Total: 1, + TotalPages: 1, + }, + } + json.NewEncoder(w).Encode(payload) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + ResponseFormatter()(handler).ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp utils.APIResponse + err := json.Unmarshal(rec.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.True(t, resp.Success) + assert.NotNil(t, resp.Meta) + meta := resp.Meta.(map[string]interface{}) + assert.Equal(t, float64(1), meta["page"]) +} diff --git a/internal/shared/middleware/idempotency.go b/internal/shared/middleware/idempotency.go index 7aa82bb..bc5bd09 100644 --- a/internal/shared/middleware/idempotency.go +++ b/internal/shared/middleware/idempotency.go @@ -44,7 +44,7 @@ func Idempotency(rdb *redis.Client, ttl time.Duration) func(http.Handler) http.H w.Header().Set("Content-Type", "application/json") w.Header().Set("Idempotency-Key", key) w.WriteHeader(entry.StatusCode) - w.Write(entry.Body) + _, _ = w.Write(entry.Body) return } } diff --git a/internal/shared/middleware/metrics.go b/internal/shared/middleware/metrics.go new file mode 100644 index 0000000..cae1997 --- /dev/null +++ b/internal/shared/middleware/metrics.go @@ -0,0 +1,60 @@ +package middleware + +import ( + "net/http" + "strconv" + "time" + + "github.com/IDTS-LAB/go-codebase/internal/monitoring/domain" +) + +const ( + MetricRequestsTotal = "http_requests_total" + MetricRequestDuration = "http_request_duration_seconds" + MetricRequestsActive = "http_requests_active" + MetricRequestSize = "http_request_size_bytes" + MetricResponseSize = "http_response_size_bytes" +) + +func Metrics(recorder domain.MetricsRecorder) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + recorder.IncrementCounter(r.Context(), MetricRequestsActive, "method", r.Method, "path", r.URL.Path, "status", "0") + defer recorder.IncrementCounter(r.Context(), MetricRequestsActive, "method", r.Method, "path", r.URL.Path, "status", "0") + + if r.ContentLength > 0 { + recorder.ObserveHistogram(r.Context(), MetricRequestSize, float64(r.ContentLength), "method", r.Method, "path", r.URL.Path, "status", "0") + } + + wrapped := &metricsWriter{ResponseWriter: w, statusCode: http.StatusOK} + next.ServeHTTP(wrapped, r.WithContext(r.Context())) + + status := strconv.Itoa(wrapped.statusCode) + duration := time.Since(start).Seconds() + + recorder.IncrementCounter(r.Context(), MetricRequestsTotal, "method", r.Method, "path", r.URL.Path, "status", status) + recorder.ObserveHistogram(r.Context(), MetricRequestDuration, duration, "method", r.Method, "path", r.URL.Path, "status", status) + if wrapped.bodySize > 0 { + recorder.ObserveHistogram(r.Context(), MetricResponseSize, float64(wrapped.bodySize), "method", r.Method, "path", r.URL.Path, "status", status) + } + }) + } +} + +type metricsWriter struct { + http.ResponseWriter + statusCode int + bodySize int +} + +func (w *metricsWriter) WriteHeader(code int) { + w.statusCode = code + w.ResponseWriter.WriteHeader(code) +} + +func (w *metricsWriter) Write(b []byte) (int, error) { + n, err := w.ResponseWriter.Write(b) + w.bodySize += n + return n, err +} diff --git a/internal/shared/middleware/middleware.go b/internal/shared/middleware/middleware.go index 9ab3eb0..8adcb51 100644 --- a/internal/shared/middleware/middleware.go +++ b/internal/shared/middleware/middleware.go @@ -13,17 +13,23 @@ import ( "github.com/IDTS-LAB/go-codebase/internal/core/domain" "github.com/IDTS-LAB/go-codebase/internal/shared/auditlog" "github.com/IDTS-LAB/go-codebase/internal/shared/config" + "github.com/IDTS-LAB/go-codebase/internal/shared/utils" "github.com/google/uuid" "github.com/rs/cors" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" ) type contextKey string const ( - RequestIDKey contextKey = "request_id" - UserIDKey contextKey = "user_id" - UserEmailKey contextKey = "user_email" - UserRoleKey contextKey = "user_role" + RequestIDKey contextKey = "request_id" + UserIDKey contextKey = "user_id" + UserEmailKey contextKey = "user_email" + UserRoleKey contextKey = "user_role" + TenantIDKey contextKey = "tenant_id" + TenantClaimKey contextKey = "tenant_claim" ) func ErrorHandler(log domain.Logger, errorRepo *auditlog.Repository) func(http.Handler) http.Handler { @@ -32,15 +38,24 @@ func ErrorHandler(log domain.Logger, errorRepo *auditlog.Repository) func(http.H defer func() { if err := recover(); err != nil { stack := string(debug.Stack()) - log.Error(r.Context(), "panic recovered", + ctx := r.Context() + + if span := trace.SpanFromContext(ctx); span.IsRecording() { + span.SetStatus(codes.Error, "panic recovered") + span.RecordError(fmt.Errorf("%v", err)) + } + + log.Error(ctx, "panic recovered", domain.String("error", fmt.Sprintf("%v", err)), domain.String("stack", stack), ) + ctx = utils.SetErrorInfo(ctx, fmt.Errorf("%v", err), stack) + persistError(r, errorRepo, log, http.StatusInternalServerError, "panic recovered", fmt.Sprintf("%v", err), stack) - http.Error(w, `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"internal server error"}}`, http.StatusInternalServerError) + utils.RespondInternalErrorFromRequest(w, r.WithContext(ctx), fmt.Sprintf("%v", err)) } }() next.ServeHTTP(w, r) @@ -55,8 +70,27 @@ func ErrorRecorder(log domain.Logger, errorRepo *auditlog.Repository) func(http. next.ServeHTTP(wrapped, r) if wrapped.statusCode >= 500 { - persistError(r, errorRepo, log, wrapped.statusCode, - http.StatusText(wrapped.statusCode), wrapped.errMsg, wrapped.stack) + ctx := r.Context() + msg := http.StatusText(wrapped.statusCode) + errMsg := wrapped.errMsg + stack := wrapped.stack + if info, ok := utils.GetErrorInfo(ctx); ok && info.Err != nil { + msg = info.Err.Error() + errMsg = info.Err.Error() + stack = info.Stack + } + + if span := trace.SpanFromContext(ctx); span.IsRecording() { + span.SetStatus(codes.Error, msg) + span.RecordError(fmt.Errorf("%s", errMsg)) + span.AddEvent("exception", + trace.WithAttributes( + attribute.String("exception.message", errMsg), + ), + ) + } + + persistError(r, errorRepo, log, wrapped.statusCode, msg, errMsg, stack) } }) } @@ -79,6 +113,7 @@ func persistError(r *http.Request, repo *auditlog.Repository, log domain.Logger, StatusCode: status, IP: r.RemoteAddr, UserAgent: r.UserAgent(), + TenantID: GetTenantID(r.Context()), CreatedAt: time.Now(), } @@ -172,7 +207,7 @@ func Authentication(tokenSvc domain.TokenService) func(http.Handler) http.Handle return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenStr := r.Header.Get("Authorization") if tokenStr == "" { - http.Error(w, `{"success":false,"error":{"code":"UNAUTHORIZED","message":"missing token"}}`, http.StatusUnauthorized) + utils.RespondUnauthorized(w, "missing token") return } @@ -182,13 +217,14 @@ func Authentication(tokenSvc domain.TokenService) func(http.Handler) http.Handle claims, err := tokenSvc.ValidateToken(tokenStr) if err != nil { - http.Error(w, `{"success":false,"error":{"code":"UNAUTHORIZED","message":"invalid token"}}`, http.StatusUnauthorized) + utils.RespondUnauthorized(w, "invalid token") return } ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID) ctx = context.WithValue(ctx, UserEmailKey, claims.Email) ctx = context.WithValue(ctx, UserRoleKey, claims.Role) + ctx = context.WithValue(ctx, TenantClaimKey, claims.TenantID) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -199,7 +235,7 @@ func AuthenticationWithDenylist(tokenSvc domain.TokenService, denylistChecker fu return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenStr := r.Header.Get("Authorization") if tokenStr == "" { - http.Error(w, `{"success":false,"error":{"code":"UNAUTHORIZED","message":"missing token"}}`, http.StatusUnauthorized) + utils.RespondUnauthorized(w, "missing token") return } @@ -209,18 +245,19 @@ func AuthenticationWithDenylist(tokenSvc domain.TokenService, denylistChecker fu claims, err := tokenSvc.ValidateToken(tokenStr) if err != nil { - http.Error(w, `{"success":false,"error":{"code":"UNAUTHORIZED","message":"invalid token"}}`, http.StatusUnauthorized) + utils.RespondUnauthorized(w, "invalid token") return } if claims.JTI != "" && denylistChecker(r.Context(), claims.JTI) { - http.Error(w, `{"success":false,"error":{"code":"UNAUTHORIZED","message":"token has been revoked"}}`, http.StatusUnauthorized) + utils.RespondUnauthorized(w, "token has been revoked") return } ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID) ctx = context.WithValue(ctx, UserEmailKey, claims.Email) ctx = context.WithValue(ctx, UserRoleKey, claims.Role) + ctx = context.WithValue(ctx, TenantClaimKey, claims.TenantID) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -275,6 +312,13 @@ func GetUserRole(ctx context.Context) string { return "" } +func GetTenantID(ctx context.Context) string { + if v, ok := ctx.Value(TenantIDKey).(string); ok { + return v + } + return "" +} + type Authorizer interface { Enforce(userID uuid.UUID, resource, action string) (bool, error) } @@ -284,24 +328,24 @@ func Authorization(authorizer Authorizer, resource, action string) func(http.Han return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID := GetUserID(r.Context()) if userID == "" { - http.Error(w, `{"success":false,"error":{"code":"UNAUTHORIZED","message":"user not authenticated"}}`, http.StatusUnauthorized) + utils.RespondUnauthorized(w, "user not authenticated") return } uid, err := uuid.Parse(userID) if err != nil { - http.Error(w, `{"success":false,"error":{"code":"UNAUTHORIZED","message":"invalid user ID"}}`, http.StatusUnauthorized) + utils.RespondUnauthorized(w, "invalid user ID") return } allowed, err := authorizer.Enforce(uid, resource, action) if err != nil { - http.Error(w, `{"success":false,"error":{"code":"INTERNAL_ERROR","message":"authorization check failed"}}`, http.StatusInternalServerError) + utils.RespondInternalError(w, "authorization check failed") return } if !allowed { - http.Error(w, `{"success":false,"error":{"code":"FORBIDDEN","message":"insufficient permissions"}}`, http.StatusForbidden) + utils.RespondForbidden(w, "FORBIDDEN", "insufficient permissions") return } diff --git a/internal/shared/middleware/ratelimit.go b/internal/shared/middleware/ratelimit.go index bee0d95..e3c19e3 100644 --- a/internal/shared/middleware/ratelimit.go +++ b/internal/shared/middleware/ratelimit.go @@ -1,12 +1,12 @@ package middleware import ( - "context" "fmt" "net/http" "strconv" "time" + "github.com/IDTS-LAB/go-codebase/internal/shared/utils" "github.com/redis/go-redis/v9" ) @@ -14,12 +14,9 @@ func RateLimit(rdb *redis.Client, limit int, window time.Duration) func(http.Han return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip := r.RemoteAddr - if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { - ip = fwd - } key := fmt.Sprintf("ratelimit:%s", ip) - ctx := context.Background() + ctx := r.Context() count, err := rdb.Incr(ctx, key).Result() if err != nil { @@ -38,7 +35,7 @@ func RateLimit(rdb *redis.Client, limit int, window time.Duration) func(http.Han if count > int64(limit) { w.Header().Set("Retry-After", strconv.Itoa(int(ttl.Seconds())+1)) - http.Error(w, `{"success":false,"error":{"code":"RATE_LIMITED","message":"too many requests"}}`, http.StatusTooManyRequests) + utils.RespondError(w, http.StatusTooManyRequests, "RATE_LIMITED", "too many requests") return } diff --git a/internal/shared/middleware/registry.go b/internal/shared/middleware/registry.go index 2a19382..e3f0c9f 100644 --- a/internal/shared/middleware/registry.go +++ b/internal/shared/middleware/registry.go @@ -5,9 +5,10 @@ import ( "net/http" "time" + coredomain "github.com/IDTS-LAB/go-codebase/internal/core/domain" + monitoringdomain "github.com/IDTS-LAB/go-codebase/internal/monitoring/domain" "github.com/IDTS-LAB/go-codebase/internal/shared/auditlog" "github.com/IDTS-LAB/go-codebase/internal/shared/config" - "github.com/IDTS-LAB/go-codebase/internal/core/domain" "github.com/redis/go-redis/v9" ) @@ -20,16 +21,19 @@ type Registry struct { RateLimit func(http.Handler) http.Handler Idempotency func(http.Handler) http.Handler MaxBodySize func(http.Handler) http.Handler + Tracing func(http.Handler) http.Handler + Metrics func(http.Handler) http.Handler Authorizer Authorizer } func NewRegistry( - tokenSvc domain.TokenService, + tokenSvc coredomain.TokenService, rdb *redis.Client, cfg *config.Config, - log domain.Logger, + log coredomain.Logger, errorRepo *auditlog.Repository, authorizer Authorizer, + metricsRecorder monitoringdomain.MetricsRecorder, ) Registry { auth := Authentication(tokenSvc) if cfg.Auth.TokenDenylist { @@ -42,11 +46,13 @@ func NewRegistry( Auth: auth, Logger: Logger(log), ErrorHandler: ErrorHandler(log, errorRepo), - ErrorRecorder: ErrorRecorder(nil, errorRepo), + ErrorRecorder: ErrorRecorder(log, errorRepo), AuditLog: AuditLog(errorRepo), RateLimit: RateLimit(rdb, cfg.RateLimit.Requests, time.Duration(cfg.RateLimit.Window)*time.Second), Idempotency: Idempotency(rdb, time.Duration(cfg.Idempotency.TTL)*time.Second), MaxBodySize: MaxBodySize(int64(cfg.Server.MaxRequestBodySize)), + Tracing: Tracing(), + Metrics: Metrics(metricsRecorder), Authorizer: authorizer, } } diff --git a/internal/shared/middleware/tenant.go b/internal/shared/middleware/tenant.go new file mode 100644 index 0000000..a71ec58 --- /dev/null +++ b/internal/shared/middleware/tenant.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "github.com/IDTS-LAB/go-codebase/internal/shared/config" +) + +func TenantResolver(cfg *config.TenantConfig) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenantID := "" + if cfg.Enabled { + tenantID = resolveTenant(r, cfg) + } + ctx := context.WithValue(r.Context(), TenantIDKey, tenantID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func resolveTenant(r *http.Request, cfg *config.TenantConfig) string { + if tid := GetTenantIDFromClaims(r.Context()); tid != "" { + return tid + } + if h := r.Header.Get(cfg.TenantHeader); h != "" { + return h + } + if sub := domainFromHost(r.Host, cfg.Domain); sub != "" && sub != "www" { + return sub + } + return "" +} + +func domainFromHost(host, domainSuffix string) string { + host = strings.Split(host, ":")[0] + if !strings.HasSuffix(host, "."+domainSuffix) { + return "" + } + return strings.TrimSuffix(host, "."+domainSuffix) +} + +func GetTenantIDFromClaims(ctx context.Context) string { + if v, ok := ctx.Value(TenantClaimKey).(string); ok { + return v + } + return "" +} diff --git a/internal/shared/middleware/tracing.go b/internal/shared/middleware/tracing.go new file mode 100644 index 0000000..d966aea --- /dev/null +++ b/internal/shared/middleware/tracing.go @@ -0,0 +1,51 @@ +package middleware + +import ( + "fmt" + "net/http" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" +) + +const tracerName = "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" + +// Tracing starts a new OpenTelemetry span for each HTTP request, propagates +// incoming trace context, and records the response status on the span. +func Tracing() func(http.Handler) http.Handler { + tracer := otel.Tracer(tracerName) + propagator := otel.GetTextMapPropagator() + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := propagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header)) + + ctx, span := tracer.Start(ctx, fmt.Sprintf("%s %s", r.Method, r.URL.Path), + trace.WithAttributes( + attribute.String("http.method", r.Method), + attribute.String("http.path", r.URL.Path), + attribute.String("http.route", r.URL.Path), + attribute.String("http.target", r.URL.String()), + attribute.String("http.scheme", r.URL.Scheme), + attribute.String("http.flavor", r.Proto), + attribute.String("net.host.name", r.Host), + ), + ) + defer span.End() + + wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + next.ServeHTTP(wrapped, r.WithContext(ctx)) + + status := wrapped.statusCode + span.SetAttributes(attribute.Int("http.status_code", status)) + if status >= 500 { + span.SetStatus(codes.Error, fmt.Sprintf("server error: %d", status)) + } else if status >= 400 { + span.SetStatus(codes.Error, fmt.Sprintf("client error: %d", status)) + } + }) + } +} diff --git a/internal/shared/router/router.go b/internal/shared/router/router.go index 6e64568..8f7b7fc 100644 --- a/internal/shared/router/router.go +++ b/internal/shared/router/router.go @@ -1,24 +1,34 @@ package router import ( + "database/sql" + "net/http" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" "github.com/IDTS-LAB/go-codebase/internal/shared/config" "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" + "github.com/IDTS-LAB/go-codebase/internal/shared/utils" "github.com/go-chi/chi/v5" ) const APIPrefix = "/api/v1" type Handlers struct { - Auth *chi.Mux - Todo *chi.Mux - Authz *chi.Mux - User *chi.Mux + Auth *chi.Mux + Todo *chi.Mux + Authz *chi.Mux + User *chi.Mux + Tenant *chi.Mux + MetricsHandler http.Handler + DebugNATSHandler http.Handler } -func NewRouter(h Handlers, mw middleware.Registry, log domain.Logger, cfg *config.Config) *chi.Mux { +func NewRouter(h Handlers, mw middleware.Registry, log domain.Logger, cfg *config.Config, db *sql.DB) *chi.Mux { + utils.IsProduction = cfg.App.Env == "production" + r := chi.NewRouter() + r.Use(mw.Tracing) r.Use(middleware.RequestID) r.Use(mw.ErrorHandler) r.Use(middleware.SecurityHeaders) @@ -26,11 +36,22 @@ func NewRouter(h Handlers, mw middleware.Registry, log domain.Logger, cfg *confi r.Use(mw.ErrorRecorder) r.Use(mw.AuditLog) r.Use(mw.RateLimit) + r.Use(mw.Metrics) r.Use(middleware.Logger(log)) - registerWeb(r, cfg) + registerWeb(r, cfg, db) + + if h.MetricsHandler != nil { + r.Handle("/metrics", h.MetricsHandler) + } + + if h.DebugNATSHandler != nil { + r.Get("/debug/nats", h.DebugNATSHandler.ServeHTTP) + } r.Route(APIPrefix, func(r chi.Router) { + r.Use(middleware.ResponseFormatter()) + r.Group(func(r chi.Router) { r.Use(mw.MaxBodySize) r.Use(mw.Idempotency) @@ -39,10 +60,12 @@ func NewRouter(h Handlers, mw middleware.Registry, log domain.Logger, cfg *confi r.Group(func(r chi.Router) { r.Use(mw.Auth) + r.Use(middleware.TenantResolver(&cfg.Tenant)) r.Use(mw.MaxBodySize) r.Mount("/todos", h.Todo) r.Mount("/users", h.User) r.Mount("/auth/sessions", h.Authz) + r.Mount("/admin/tenants", h.Tenant) }) }) diff --git a/internal/shared/router/web.go b/internal/shared/router/web.go index e0e353d..693dd57 100644 --- a/internal/shared/router/web.go +++ b/internal/shared/router/web.go @@ -1,6 +1,8 @@ package router import ( + "database/sql" + "encoding/json" "net/http" "github.com/IDTS-LAB/go-codebase/docs" @@ -9,18 +11,26 @@ import ( httpSwagger "github.com/swaggo/http-swagger" ) -func registerWeb(r chi.Router, cfg *config.Config) { +func registerWeb(r chi.Router, cfg *config.Config, db *sql.DB) { r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"ok"}`)) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) }) r.Get("/ready", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := db.PingContext(r.Context()); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "unhealthy"}) + return + } w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"ready"}`)) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ready"}) }) r.Get("/live", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"alive"}`)) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "alive"}) }) if cfg.App.Env != "production" { diff --git a/internal/shared/telemetry/error.go b/internal/shared/telemetry/error.go new file mode 100644 index 0000000..d5d5e33 --- /dev/null +++ b/internal/shared/telemetry/error.go @@ -0,0 +1,54 @@ +package telemetry + +import ( + "context" + "fmt" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// RecordError records an application error on the current span. +func RecordError(ctx context.Context, err error) { + if err == nil { + return + } + + span := trace.SpanFromContext(ctx) + if !span.IsRecording() { + return + } + + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + span.AddEvent("exception", + trace.WithAttributes( + attribute.String("exception.type", fmt.Sprintf("%T", err)), + attribute.String("exception.message", err.Error()), + ), + ) +} + +// RecordPanic records a recovered panic together with its stack trace. +func RecordPanic(ctx context.Context, recovered any, stack string) { + span := trace.SpanFromContext(ctx) + if !span.IsRecording() { + return + } + + err := fmt.Errorf("%v", recovered) + + span.RecordError(err) + + span.SetStatus(codes.Error, "panic") + + span.AddEvent("exception", + trace.WithAttributes( + attribute.String("exception.type", "panic"), + attribute.String("exception.message", err.Error()), + attribute.String("exception.stacktrace", stack), + ), + ) +} diff --git a/internal/shared/telemetry/telemetry.go b/internal/shared/telemetry/telemetry.go index 0d2f2a1..e61650f 100644 --- a/internal/shared/telemetry/telemetry.go +++ b/internal/shared/telemetry/telemetry.go @@ -7,6 +7,7 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.24.0" @@ -14,9 +15,12 @@ import ( "go.uber.org/zap" ) -var Module = fx.Module("telemetry", fx.Provide(NewTracerProvider)) +var Module = fx.Module("telemetry", + fx.Provide(NewTracerProvider), + fx.Invoke(func(*sdktrace.TracerProvider) {}), +) -func NewTracerProvider(cfg *config.Config, log *zap.Logger) (*sdktrace.TracerProvider, error) { +func NewTracerProvider(cfg *config.Config, log *zap.Logger, lc fx.Lifecycle) (*sdktrace.TracerProvider, error) { if cfg.Telemetry.ExporterEndpoint == "" { log.Warn("telemetry exporter endpoint not configured, tracing disabled") tp := sdktrace.NewTracerProvider() @@ -40,7 +44,7 @@ func NewTracerProvider(cfg *config.Config, log *zap.Logger) (*sdktrace.TracerPro res, err := resource.New(ctx, resource.WithAttributes( semconv.ServiceNameKey.String(cfg.Telemetry.ServiceName), - attribute.String("environment", "production"), + attribute.String("environment", cfg.App.Env), ), ) if err != nil { @@ -54,10 +58,25 @@ func NewTracerProvider(cfg *config.Config, log *zap.Logger) (*sdktrace.TracerPro ) otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) log.Info("telemetry initialized", zap.String("service", cfg.Telemetry.ServiceName), zap.String("endpoint", cfg.Telemetry.ExporterEndpoint), ) + lc.Append(fx.Hook{ + OnStop: func(ctx context.Context) error { + if err := tp.Shutdown(ctx); err != nil { + log.Warn("failed to shutdown tracer provider", zap.Error(err)) + return err + } + log.Info("tracer provider shutdown complete") + return nil + }, + }) + return tp, nil } diff --git a/internal/shared/tenantfilter/filter.go b/internal/shared/tenantfilter/filter.go new file mode 100644 index 0000000..7aaef26 --- /dev/null +++ b/internal/shared/tenantfilter/filter.go @@ -0,0 +1,31 @@ +package tenantfilter + +import ( + "context" + "fmt" + + "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" +) + +type Config struct { + Enabled bool +} + +func Where(ctx context.Context, config *Config, nextPosition int) (string, interface{}) { + if config == nil || !config.Enabled { + return "", nil + } + tenantID := middleware.GetTenantID(ctx) + if tenantID == "" { + return "", nil + } + return fmt.Sprintf("tenant_id = $%d", nextPosition), tenantID +} + +func WhereAnd(ctx context.Context, config *Config, nextPosition int) (string, interface{}) { + clause, val := Where(ctx, config, nextPosition) + if clause == "" { + return "", nil + } + return "AND " + clause, val +} diff --git a/internal/shared/utils/error_context.go b/internal/shared/utils/error_context.go new file mode 100644 index 0000000..4b70a4c --- /dev/null +++ b/internal/shared/utils/error_context.go @@ -0,0 +1,26 @@ +package utils + +import ( + "context" +) + +type contextKey string + +const errorInfoKey contextKey = "error_info" + +type ErrorInfo struct { + Err error + Stack string +} + +func SetErrorInfo(ctx context.Context, err error, stack string) context.Context { + return context.WithValue(ctx, errorInfoKey, &ErrorInfo{Err: err, Stack: stack}) +} + +func GetErrorInfo(ctx context.Context) (*ErrorInfo, bool) { + info, ok := ctx.Value(errorInfoKey).(*ErrorInfo) + if !ok { + return nil, false + } + return info, true +} diff --git a/internal/shared/utils/handler.go b/internal/shared/utils/handler.go new file mode 100644 index 0000000..2cc2e1a --- /dev/null +++ b/internal/shared/utils/handler.go @@ -0,0 +1,51 @@ +package utils + +import "net/http" + +// Handle writes a standard 200 success response, or maps the error to the +// unified error response envelope. +func Handle(w http.ResponseWriter, r *http.Request, data interface{}, err error) { + if err != nil { + MapErrorFromRequest(w, r, err) + return + } + RespondSuccess(w, data) +} + +// HandleCreated writes a standard 201 created response, or maps the error to +// the unified error response envelope. +func HandleCreated(w http.ResponseWriter, r *http.Request, data interface{}, err error) { + if err != nil { + MapErrorFromRequest(w, r, err) + return + } + RespondCreated(w, data) +} + +// HandleNoContent writes a standard 200 success response with nil data, or +// maps the error to the unified error response envelope. +func HandleNoContent(w http.ResponseWriter, r *http.Request, err error) { + if err != nil { + MapErrorFromRequest(w, r, err) + return + } + RespondSuccess(w, nil) +} + +// HandlePaginated writes a standard 200 paginated response, or maps the error +// to the unified error response envelope. +func HandlePaginated(w http.ResponseWriter, r *http.Request, data interface{}, page, perPage, total int, err error) { + if err != nil { + MapErrorFromRequest(w, r, err) + return + } + RespondPaginated(w, data, page, perPage, total) +} + +func HandleCursorPaginated(w http.ResponseWriter, r *http.Request, data interface{}, nextCursor, prevCursor *string, hasNext, hasPrev bool, limit int, err error) { + if err != nil { + MapErrorFromRequest(w, r, err) + return + } + RespondCursorPaginated(w, data, nextCursor, prevCursor, hasNext, hasPrev, limit) +} diff --git a/internal/shared/utils/utils.go b/internal/shared/utils/utils.go index 7c622b7..5299a76 100644 --- a/internal/shared/utils/utils.go +++ b/internal/shared/utils/utils.go @@ -2,17 +2,40 @@ package utils import ( "encoding/json" + "errors" + "fmt" "net/http" + "runtime/debug" + + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" ) -type SuccessResponse struct { +var IsProduction bool + +type APIResponse struct { Success bool `json:"success"` Data interface{} `json:"data,omitempty"` + Meta interface{} `json:"meta,omitempty"` + Error *ErrorBody `json:"error,omitempty"` + Stack string `json:"stack,omitempty"` } -type ErrorResponse struct { - Success bool `json:"success"` - Error ErrorBody `json:"error"` +type PaginationMeta struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` +} + +type CursorMeta struct { + NextCursor *string `json:"next_cursor"` + PrevCursor *string `json:"prev_cursor"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` + Limit int `json:"limit"` } type ErrorBody struct { @@ -20,50 +43,140 @@ type ErrorBody struct { Message string `json:"message"` } +type PaginatedPayload[T any] struct { + Data []T `json:"data"` + Pagination PaginationMeta `json:"pagination"` +} + +type PaginatedResult[T any] struct { + Data []T + Page int + PerPage int + Total int +} + func RespondJSON(w http.ResponseWriter, status int, payload interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) - if payload != nil { - json.NewEncoder(w).Encode(payload) - } + _ = json.NewEncoder(w).Encode(payload) } func RespondSuccess(w http.ResponseWriter, data interface{}) { - RespondJSON(w, http.StatusOK, SuccessResponse{ + RespondJSON(w, http.StatusOK, APIResponse{Success: true, Data: data}) +} + +func RespondCreated(w http.ResponseWriter, data interface{}) { + RespondJSON(w, http.StatusCreated, APIResponse{Success: true, Data: data}) +} + +func RespondPaginated(w http.ResponseWriter, data interface{}, page, perPage, total int) { + totalPages := (total + perPage - 1) / perPage + if totalPages < 0 { + totalPages = 0 + } + RespondJSON(w, http.StatusOK, APIResponse{ Success: true, Data: data, + Meta: &PaginationMeta{ + Page: page, + PerPage: perPage, + Total: total, + TotalPages: totalPages, + }, }) } -func RespondCreated(w http.ResponseWriter, data interface{}) { - RespondJSON(w, http.StatusCreated, SuccessResponse{ +func RespondCursorPaginated(w http.ResponseWriter, data interface{}, nextCursor, prevCursor *string, hasNext, hasPrev bool, limit int) { + RespondJSON(w, http.StatusOK, APIResponse{ Success: true, Data: data, + Meta: CursorMeta{ + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + Limit: limit, + }, }) } func RespondError(w http.ResponseWriter, status int, code, message string) { - RespondJSON(w, status, ErrorResponse{ + RespondJSON(w, status, APIResponse{ Success: false, - Error: ErrorBody{ - Code: code, - Message: message, - }, + Error: &ErrorBody{Code: code, Message: message}, }) } func RespondBadRequest(w http.ResponseWriter, message string) { - RespondError(w, http.StatusBadRequest, "BAD_REQUEST", message) + RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", message) +} + +func RespondUnauthorized(w http.ResponseWriter, message string) { + RespondError(w, http.StatusUnauthorized, "UNAUTHORIZED", message) +} + +func RespondForbidden(w http.ResponseWriter, code, message string) { + RespondError(w, http.StatusForbidden, code, message) } func RespondNotFound(w http.ResponseWriter, message string) { RespondError(w, http.StatusNotFound, "NOT_FOUND", message) } +func RespondConflict(w http.ResponseWriter, message string) { + RespondError(w, http.StatusConflict, "CONFLICT", message) +} + func RespondInternalError(w http.ResponseWriter, message string) { RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", message) } -func RespondConflict(w http.ResponseWriter, message string) { - RespondError(w, http.StatusConflict, "CONFLICT", message) +func RespondInternalErrorFromRequest(w http.ResponseWriter, r *http.Request, message string) { + if IsProduction { + RespondInternalError(w, "internal server error") + return + } + resp := APIResponse{ + Success: false, + Error: &ErrorBody{Code: "INTERNAL_ERROR", Message: message}, + } + if info, ok := GetErrorInfo(r.Context()); ok { + resp.Stack = info.Stack + } + RespondJSON(w, http.StatusInternalServerError, resp) +} + +func MapErrorFromRequest(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, domain.ErrNotFound): + RespondNotFound(w, err.Error()) + case errors.Is(err, domain.ErrAlreadyExists) || errors.Is(err, domain.ErrConflict): + RespondConflict(w, err.Error()) + case errors.Is(err, domain.ErrValidation): + RespondBadRequest(w, err.Error()) + case errors.Is(err, domain.ErrForbidden): + RespondForbidden(w, "FORBIDDEN", err.Error()) + case errors.Is(err, domain.ErrUnauthorized): + RespondUnauthorized(w, err.Error()) + default: + stack := string(debug.Stack()) + span := trace.SpanFromContext(r.Context()) + if span.IsRecording() { + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + span.AddEvent("exception", + trace.WithAttributes( + attribute.String("exception.type", fmt.Sprintf("%T", err)), + attribute.String("exception.message", err.Error()), + attribute.String("exception.stacktrace", stack), + ), + ) + } + ctx := SetErrorInfo(r.Context(), err, stack) + msg := "internal server error" + if !IsProduction { + msg = err.Error() + } + RespondInternalErrorFromRequest(w, r.WithContext(ctx), msg) + } } diff --git a/internal/tenant/application/command/create_tenant.go b/internal/tenant/application/command/create_tenant.go new file mode 100644 index 0000000..4c79ccc --- /dev/null +++ b/internal/tenant/application/command/create_tenant.go @@ -0,0 +1,68 @@ +package command + +import ( + "context" + "encoding/json" + "time" + + coreDomain "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/dto" + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/repository" + "github.com/google/uuid" +) + +type CreateTenantCommand struct { + Name string + Slug string + Domain *string + Settings json.RawMessage +} + +type CreateTenantHandler struct { + repo repository.TenantRepository +} + +func NewCreateTenantHandler(repo repository.TenantRepository) *CreateTenantHandler { + return &CreateTenantHandler{repo: repo} +} + +func (h *CreateTenantHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(CreateTenantCommand) + existing, _ := h.repo.GetBySlug(ctx, c.Slug) + if existing != nil { + return nil, coreDomain.NewDomainError(coreDomain.ErrAlreadyExists, "TENANT_EXISTS", "tenant with this slug already exists") + } + + now := time.Now() + settings := c.Settings + if settings == nil { + settings = json.RawMessage("{}") + } + + tenant := &entity.Tenant{ + ID: uuid.New(), + Name: c.Name, + Slug: c.Slug, + Domain: c.Domain, + Settings: settings, + IsActive: true, + CreatedAt: now, + UpdatedAt: now, + } + + if err := h.repo.Create(ctx, tenant); err != nil { + return nil, err + } + + return dto.TenantResponse{ + ID: tenant.ID.String(), + Name: tenant.Name, + Slug: tenant.Slug, + Domain: tenant.Domain, + Settings: tenant.Settings, + IsActive: tenant.IsActive, + CreatedAt: tenant.CreatedAt.Format(time.RFC3339Nano), + UpdatedAt: tenant.UpdatedAt.Format(time.RFC3339Nano), + }, nil +} diff --git a/internal/tenant/application/command/delete_tenant.go b/internal/tenant/application/command/delete_tenant.go new file mode 100644 index 0000000..dbccee8 --- /dev/null +++ b/internal/tenant/application/command/delete_tenant.go @@ -0,0 +1,30 @@ +package command + +import ( + "context" + + coreDomain "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/repository" + "github.com/google/uuid" +) + +type DeleteTenantCommand struct { + ID uuid.UUID +} + +type DeleteTenantHandler struct { + repo repository.TenantRepository +} + +func NewDeleteTenantHandler(repo repository.TenantRepository) *DeleteTenantHandler { + return &DeleteTenantHandler{repo: repo} +} + +func (h *DeleteTenantHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(DeleteTenantCommand) + _, err := h.repo.GetByID(ctx, c.ID) + if err != nil { + return nil, coreDomain.NewDomainError(coreDomain.ErrNotFound, "TENANT_NOT_FOUND", "tenant not found") + } + return nil, h.repo.Delete(ctx, c.ID) +} diff --git a/internal/tenant/application/command/update_tenant.go b/internal/tenant/application/command/update_tenant.go new file mode 100644 index 0000000..708fe2d --- /dev/null +++ b/internal/tenant/application/command/update_tenant.go @@ -0,0 +1,65 @@ +package command + +import ( + "context" + "encoding/json" + "time" + + coreDomain "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/dto" + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/repository" + "github.com/google/uuid" +) + +type UpdateTenantCommand struct { + ID uuid.UUID + Name *string + Domain *string + Settings json.RawMessage + IsActive *bool +} + +type UpdateTenantHandler struct { + repo repository.TenantRepository +} + +func NewUpdateTenantHandler(repo repository.TenantRepository) *UpdateTenantHandler { + return &UpdateTenantHandler{repo: repo} +} + +func (h *UpdateTenantHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(UpdateTenantCommand) + tenant, err := h.repo.GetByID(ctx, c.ID) + if err != nil { + return nil, coreDomain.NewDomainError(coreDomain.ErrNotFound, "TENANT_NOT_FOUND", "tenant not found") + } + + if c.Name != nil { + tenant.Name = *c.Name + } + if c.Domain != nil { + tenant.Domain = c.Domain + } + if c.Settings != nil { + tenant.Settings = c.Settings + } + if c.IsActive != nil { + tenant.IsActive = *c.IsActive + } + tenant.UpdatedAt = time.Now() + + if err := h.repo.Update(ctx, tenant); err != nil { + return nil, err + } + + return dto.TenantResponse{ + ID: tenant.ID.String(), + Name: tenant.Name, + Slug: tenant.Slug, + Domain: tenant.Domain, + Settings: tenant.Settings, + IsActive: tenant.IsActive, + CreatedAt: tenant.CreatedAt.Format(time.RFC3339Nano), + UpdatedAt: tenant.UpdatedAt.Format(time.RFC3339Nano), + }, nil +} diff --git a/internal/tenant/application/dto/tenant.go b/internal/tenant/application/dto/tenant.go new file mode 100644 index 0000000..2f50dbe --- /dev/null +++ b/internal/tenant/application/dto/tenant.go @@ -0,0 +1,37 @@ +package dto + +import "encoding/json" + +type CreateTenantRequest struct { + Name string `json:"name" validate:"required"` + Slug string `json:"slug" validate:"required"` + Domain *string `json:"domain"` + Settings json.RawMessage `json:"settings"` +} + +type UpdateTenantRequest struct { + Name *string `json:"name"` + Domain *string `json:"domain"` + Settings json.RawMessage `json:"settings"` + IsActive *bool `json:"is_active"` +} + +type TenantResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Domain *string `json:"domain,omitempty"` + Settings json.RawMessage `json:"settings"` + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type TenantListResponse struct { + Tenants []TenantResponse `json:"tenants"` + NextCursor *string `json:"next_cursor,omitempty"` + PrevCursor *string `json:"prev_cursor,omitempty"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` + Limit int `json:"limit"` +} diff --git a/internal/tenant/application/query/get_tenant.go b/internal/tenant/application/query/get_tenant.go new file mode 100644 index 0000000..496ee3f --- /dev/null +++ b/internal/tenant/application/query/get_tenant.go @@ -0,0 +1,41 @@ +package query + +import ( + "context" + "time" + + coreDomain "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/dto" + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/repository" + "github.com/google/uuid" +) + +type GetTenantQuery struct { + ID uuid.UUID +} + +type GetTenantHandler struct { + repo repository.TenantRepository +} + +func NewGetTenantHandler(repo repository.TenantRepository) *GetTenantHandler { + return &GetTenantHandler{repo: repo} +} + +func (h *GetTenantHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(GetTenantQuery) + tenant, err := h.repo.GetByID(ctx, q.ID) + if err != nil { + return nil, coreDomain.NewDomainError(coreDomain.ErrNotFound, "TENANT_NOT_FOUND", "tenant not found") + } + return dto.TenantResponse{ + ID: tenant.ID.String(), + Name: tenant.Name, + Slug: tenant.Slug, + Domain: tenant.Domain, + Settings: tenant.Settings, + IsActive: tenant.IsActive, + CreatedAt: tenant.CreatedAt.Format(time.RFC3339Nano), + UpdatedAt: tenant.UpdatedAt.Format(time.RFC3339Nano), + }, nil +} diff --git a/internal/tenant/application/query/list_tenants.go b/internal/tenant/application/query/list_tenants.go new file mode 100644 index 0000000..95e0f4f --- /dev/null +++ b/internal/tenant/application/query/list_tenants.go @@ -0,0 +1,52 @@ +package query + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/dto" + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/repository" +) + +type ListTenantsQuery struct { + Cursor *string + Limit int +} + +type ListTenantsHandler struct { + repo repository.TenantRepository +} + +func NewListTenantsHandler(repo repository.TenantRepository) *ListTenantsHandler { + return &ListTenantsHandler{repo: repo} +} + +func (h *ListTenantsHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(ListTenantsQuery) + tenants, nextCursor, prevCursor, hasNext, hasPrev, err := h.repo.List(ctx, q.Cursor, q.Limit) + if err != nil { + return nil, err + } + + responses := make([]dto.TenantResponse, len(tenants)) + for i, t := range tenants { + responses[i] = dto.TenantResponse{ + ID: t.ID.String(), + Name: t.Name, + Slug: t.Slug, + Domain: t.Domain, + Settings: t.Settings, + IsActive: t.IsActive, + CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + + return dto.TenantListResponse{ + Tenants: responses, + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + Limit: q.Limit, + }, nil +} diff --git a/internal/tenant/domain/entity/tenant.go b/internal/tenant/domain/entity/tenant.go new file mode 100644 index 0000000..715dcc8 --- /dev/null +++ b/internal/tenant/domain/entity/tenant.go @@ -0,0 +1,19 @@ +package entity + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type Tenant struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Domain *string `json:"domain,omitempty"` + Settings json.RawMessage `json:"settings"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/tenant/domain/repository/tenant.go b/internal/tenant/domain/repository/tenant.go new file mode 100644 index 0000000..281b35d --- /dev/null +++ b/internal/tenant/domain/repository/tenant.go @@ -0,0 +1,17 @@ +package repository + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/entity" + "github.com/google/uuid" +) + +type TenantRepository interface { + Create(ctx context.Context, t *entity.Tenant) error + GetByID(ctx context.Context, id uuid.UUID) (*entity.Tenant, error) + GetBySlug(ctx context.Context, slug string) (*entity.Tenant, error) + List(ctx context.Context, cursor *string, limit int) ([]entity.Tenant, *string, *string, bool, bool, error) + Update(ctx context.Context, t *entity.Tenant) error + Delete(ctx context.Context, id uuid.UUID) error +} diff --git a/internal/tenant/infrastructure/persistence/queries/queries.sql b/internal/tenant/infrastructure/persistence/queries/queries.sql new file mode 100644 index 0000000..2b8460c --- /dev/null +++ b/internal/tenant/infrastructure/persistence/queries/queries.sql @@ -0,0 +1,18 @@ +-- name: CreateTenant :exec +INSERT INTO tenants (id, name, slug, domain, settings, is_active, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8); + +-- name: GetTenantByID :one +SELECT id, name, slug, domain, settings, is_active, created_at, updated_at +FROM tenants WHERE id = $1; + +-- name: GetTenantBySlug :one +SELECT id, name, slug, domain, settings, is_active, created_at, updated_at +FROM tenants WHERE slug = $1; + +-- name: UpdateTenant :execrows +UPDATE tenants SET name = $2, domain = $3, settings = $4, is_active = $5, updated_at = $6 +WHERE id = $1; + +-- name: DeleteTenant :execrows +DELETE FROM tenants WHERE id = $1; diff --git a/internal/tenant/infrastructure/persistence/sqlc/db.go b/internal/tenant/infrastructure/persistence/sqlc/db.go new file mode 100644 index 0000000..5922bc9 --- /dev/null +++ b/internal/tenant/infrastructure/persistence/sqlc/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/tenant/infrastructure/persistence/sqlc/models.go b/internal/tenant/infrastructure/persistence/sqlc/models.go new file mode 100644 index 0000000..4a7067e --- /dev/null +++ b/internal/tenant/infrastructure/persistence/sqlc/models.go @@ -0,0 +1,225 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "database/sql" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type AuditLog struct { + ID uuid.UUID `json:"id"` + RequestID string `json:"request_id"` + UserID uuid.NullUUID `json:"user_id"` + UserEmail sql.NullString `json:"user_email"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int32 `json:"status_code"` + DurationMs int64 `json:"duration_ms"` + Ip string `json:"ip"` + UserAgent string `json:"user_agent"` + RequestBody sql.NullString `json:"request_body"` + ResponseSize int32 `json:"response_size"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type CasbinRule struct { + ID int32 `json:"id"` + Ptype string `json:"ptype"` + V0 sql.NullString `json:"v0"` + V1 sql.NullString `json:"v1"` + V2 sql.NullString `json:"v2"` + V3 sql.NullString `json:"v3"` + V4 sql.NullString `json:"v4"` + V5 sql.NullString `json:"v5"` +} + +type ErrorLog struct { + ID uuid.UUID `json:"id"` + RequestID string `json:"request_id"` + UserID uuid.NullUUID `json:"user_id"` + UserEmail sql.NullString `json:"user_email"` + Level string `json:"level"` + Message string `json:"message"` + Error string `json:"error"` + StackTrace string `json:"stack_trace"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int32 `json:"status_code"` + Ip string `json:"ip"` + UserAgent string `json:"user_agent"` + RequestBody sql.NullString `json:"request_body"` + Metadata []byte `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type Permission struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Resource string `json:"resource"` + Action string `json:"action"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + TenantID string `json:"tenant_id"` +} + +type RefreshToken struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + RevokedAt sql.NullTime `json:"revoked_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +type Role struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + TenantID string `json:"tenant_id"` +} + +type RolePermission struct { + RoleID uuid.UUID `json:"role_id"` + PermissionID uuid.UUID `json:"permission_id"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type Tenant struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Domain sql.NullString `json:"domain"` + Settings json.RawMessage `json:"settings"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Todo struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Completed bool `json:"completed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + TenantID string `json:"tenant_id"` +} + +type User struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + FailedLoginAttempts int32 `json:"failed_login_attempts"` + EmailVerified sql.NullBool `json:"email_verified"` + EmailVerifyToken sql.NullString `json:"email_verify_token"` + EmailVerifyExpires sql.NullTime `json:"email_verify_expires"` + PasswordResetToken sql.NullString `json:"password_reset_token"` + PasswordResetExpires sql.NullTime `json:"password_reset_expires"` + TenantID string `json:"tenant_id"` + EmailVerifiedAt sql.NullTime `json:"email_verified_at"` +} + +type UserAddress struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Label string `json:"label"` + IsDefault bool `json:"is_default"` + Street string `json:"street"` + City string `json:"city"` + State string `json:"state"` + PostalCode string `json:"postal_code"` + Country string `json:"country"` +} + +type UserCredential struct { + UserID uuid.UUID `json:"user_id"` + PasswordHash string `json:"password_hash"` + LastLoginAt sql.NullTime `json:"last_login_at"` +} + +type UserPreference struct { + UserID uuid.UUID `json:"user_id"` + Preferences json.RawMessage `json:"preferences"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserProfile struct { + UserID uuid.UUID `json:"user_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Phone sql.NullString `json:"phone"` + AvatarUrl sql.NullString `json:"avatar_url"` + Timezone string `json:"timezone"` + Locale string `json:"locale"` + Bio sql.NullString `json:"bio"` +} + +type UserRole struct { + UserID uuid.UUID `json:"user_id"` + RoleID uuid.UUID `json:"role_id"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type UserSecurity struct { + UserID uuid.UUID `json:"user_id"` + LoginAttempts int32 `json:"login_attempts"` + LockedUntil sql.NullTime `json:"locked_until"` + MfaEnabled bool `json:"mfa_enabled"` + MfaSecret sql.NullString `json:"mfa_secret"` +} + +type UserSession struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + RefreshTokenHash sql.NullString `json:"refresh_token_hash"` + DeviceInfo string `json:"device_info"` + IpAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + ExpiresAt sql.NullTime `json:"expires_at"` + LastUsedAt time.Time `json:"last_used_at"` + RevokedAt sql.NullTime `json:"revoked_at"` + CreatedAt time.Time `json:"created_at"` +} + +type UserSocialLink struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Provider string `json:"provider"` + ProviderID string `json:"provider_id"` + ProviderEmail sql.NullString `json:"provider_email"` + AvatarUrl sql.NullString `json:"avatar_url"` + CreatedAt time.Time `json:"created_at"` +} + +type UserToken struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + TokenType string `json:"token_type"` + TokenHash sql.NullString `json:"token_hash"` + ExpiresAt sql.NullTime `json:"expires_at"` + ConsumedAt sql.NullTime `json:"consumed_at"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/tenant/infrastructure/persistence/sqlc/queries.sql.go b/internal/tenant/infrastructure/persistence/sqlc/queries.sql.go new file mode 100644 index 0000000..cd43b96 --- /dev/null +++ b/internal/tenant/infrastructure/persistence/sqlc/queries.sql.go @@ -0,0 +1,128 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: queries.sql + +package sqlc + +import ( + "context" + "database/sql" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +const createTenant = `-- name: CreateTenant :exec +INSERT INTO tenants (id, name, slug, domain, settings, is_active, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +` + +type CreateTenantParams struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Domain sql.NullString `json:"domain"` + Settings json.RawMessage `json:"settings"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (q *Queries) CreateTenant(ctx context.Context, arg CreateTenantParams) error { + _, err := q.db.ExecContext(ctx, createTenant, + arg.ID, + arg.Name, + arg.Slug, + arg.Domain, + arg.Settings, + arg.IsActive, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const deleteTenant = `-- name: DeleteTenant :execrows +DELETE FROM tenants WHERE id = $1 +` + +func (q *Queries) DeleteTenant(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteTenant, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getTenantByID = `-- name: GetTenantByID :one +SELECT id, name, slug, domain, settings, is_active, created_at, updated_at +FROM tenants WHERE id = $1 +` + +func (q *Queries) GetTenantByID(ctx context.Context, id uuid.UUID) (Tenant, error) { + row := q.db.QueryRowContext(ctx, getTenantByID, id) + var i Tenant + err := row.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.Domain, + &i.Settings, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getTenantBySlug = `-- name: GetTenantBySlug :one +SELECT id, name, slug, domain, settings, is_active, created_at, updated_at +FROM tenants WHERE slug = $1 +` + +func (q *Queries) GetTenantBySlug(ctx context.Context, slug string) (Tenant, error) { + row := q.db.QueryRowContext(ctx, getTenantBySlug, slug) + var i Tenant + err := row.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.Domain, + &i.Settings, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateTenant = `-- name: UpdateTenant :execrows +UPDATE tenants SET name = $2, domain = $3, settings = $4, is_active = $5, updated_at = $6 +WHERE id = $1 +` + +type UpdateTenantParams struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Domain sql.NullString `json:"domain"` + Settings json.RawMessage `json:"settings"` + IsActive bool `json:"is_active"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (q *Queries) UpdateTenant(ctx context.Context, arg UpdateTenantParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateTenant, + arg.ID, + arg.Name, + arg.Domain, + arg.Settings, + arg.IsActive, + arg.UpdatedAt, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/tenant/infrastructure/persistence/tenant_repository.go b/internal/tenant/infrastructure/persistence/tenant_repository.go new file mode 100644 index 0000000..87f1f23 --- /dev/null +++ b/internal/tenant/infrastructure/persistence/tenant_repository.go @@ -0,0 +1,184 @@ +package persistence + +import ( + "context" + "database/sql" + "fmt" + + "github.com/IDTS-LAB/go-codebase/internal/shared/cursor" + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/tenant/infrastructure/persistence/sqlc" + "github.com/google/uuid" +) + +type tenantRepository struct { + db *sql.DB +} + +func NewTenantRepository(db *sql.DB) repository.TenantRepository { + return &tenantRepository{db: db} +} + +func (r *tenantRepository) Create(ctx context.Context, t *entity.Tenant) error { + q := sqlc.New(r.db) + err := q.CreateTenant(ctx, sqlc.CreateTenantParams{ + ID: t.ID, + Name: t.Name, + Slug: t.Slug, + Domain: ptrToNullString(t.Domain), + Settings: t.Settings, + IsActive: t.IsActive, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + }) + if err != nil { + return fmt.Errorf("insert tenant: %w", err) + } + return nil +} + +func (r *tenantRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.Tenant, error) { + q := sqlc.New(r.db) + row, err := q.GetTenantByID(ctx, id) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("tenant not found") + } + if err != nil { + return nil, fmt.Errorf("get tenant: %w", err) + } + t := mapTenantToEntity(row) + return &t, nil +} + +func (r *tenantRepository) GetBySlug(ctx context.Context, slug string) (*entity.Tenant, error) { + q := sqlc.New(r.db) + row, err := q.GetTenantBySlug(ctx, slug) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("tenant not found") + } + if err != nil { + return nil, fmt.Errorf("get tenant by slug: %w", err) + } + t := mapTenantToEntity(row) + return &t, nil +} + +func (r *tenantRepository) List(ctx context.Context, cursorArg *string, limit int) ([]entity.Tenant, *string, *string, bool, bool, error) { + args := []interface{}{} + nextPos := 1 + query := "SELECT id, name, slug, domain, settings, is_active, created_at, updated_at FROM tenants" + + if cursorArg != nil { + c, err := cursor.Decode(*cursorArg) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("invalid cursor: %w", err) + } + query += fmt.Sprintf(" WHERE (created_at, id) < ($%d, $%d)", nextPos, nextPos+1) + args = append(args, c.Timestamp, c.ID) + nextPos += 2 + } + + query += fmt.Sprintf(" ORDER BY created_at DESC, id DESC LIMIT $%d", nextPos) + args = append(args, limit+1) + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("list tenants: %w", err) + } + defer rows.Close() + + var tenants []entity.Tenant + for rows.Next() { + var t entity.Tenant + if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Domain, &t.Settings, &t.IsActive, &t.CreatedAt, &t.UpdatedAt); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("scan tenant: %w", err) + } + tenants = append(tenants, t) + } + if err := rows.Err(); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("rows iteration: %w", err) + } + + hasNext := len(tenants) > limit + if hasNext { + tenants = tenants[:limit] + } + + var nextCursor *string + var prevCursor *string + if len(tenants) > 0 { + last := tenants[len(tenants)-1] + nc := cursor.Encode(last.CreatedAt, last.ID) + nextCursor = &nc + + first := tenants[0] + pc := cursor.Encode(first.CreatedAt, first.ID) + prevCursor = &pc + } + + hasPrev := cursorArg != nil + if hasPrev && len(tenants) == 0 { + hasPrev = false + } + + return tenants, nextCursor, prevCursor, hasNext, hasPrev, nil +} + +func (r *tenantRepository) Update(ctx context.Context, t *entity.Tenant) error { + q := sqlc.New(r.db) + rows, err := q.UpdateTenant(ctx, sqlc.UpdateTenantParams{ + ID: t.ID, + Name: t.Name, + Domain: ptrToNullString(t.Domain), + Settings: t.Settings, + IsActive: t.IsActive, + UpdatedAt: t.UpdatedAt, + }) + if err != nil { + return fmt.Errorf("update tenant: %w", err) + } + if rows == 0 { + return fmt.Errorf("tenant not found") + } + return nil +} + +func (r *tenantRepository) Delete(ctx context.Context, id uuid.UUID) error { + q := sqlc.New(r.db) + rows, err := q.DeleteTenant(ctx, id) + if err != nil { + return fmt.Errorf("delete tenant: %w", err) + } + if rows == 0 { + return fmt.Errorf("tenant not found") + } + return nil +} + +func mapTenantToEntity(row sqlc.Tenant) entity.Tenant { + return entity.Tenant{ + ID: row.ID, + Name: row.Name, + Slug: row.Slug, + Domain: nullStringToPtr(row.Domain), + Settings: row.Settings, + IsActive: row.IsActive, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func ptrToNullString(s *string) sql.NullString { + if s == nil { + return sql.NullString{Valid: false} + } + return sql.NullString{String: *s, Valid: true} +} + +func nullStringToPtr(ns sql.NullString) *string { + if ns.Valid { + return &ns.String + } + return nil +} diff --git a/internal/tenant/interfaces/http/handlers.go b/internal/tenant/interfaces/http/handlers.go new file mode 100644 index 0000000..8206f33 --- /dev/null +++ b/internal/tenant/interfaces/http/handlers.go @@ -0,0 +1,127 @@ +package http + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/shared/utils" + "github.com/IDTS-LAB/go-codebase/internal/shared/validator" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/command" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/dto" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/query" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type Handler struct { + commandBus cqrs.CommandBus + queryBus cqrs.QueryBus + v *validator.Validator +} + +func NewHandler(commandBus cqrs.CommandBus, queryBus cqrs.QueryBus, v *validator.Validator) *Handler { + return &Handler{commandBus: commandBus, queryBus: queryBus, v: v} +} + +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + var req dto.CreateTenantRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondBadRequest(w, "invalid request body") + return + } + if err := h.v.Validate(req); err != nil { + utils.RespondBadRequest(w, err.Error()) + return + } + resp, err := h.commandBus.Dispatch(r.Context(), command.CreateTenantCommand{ + Name: req.Name, + Slug: req.Slug, + Domain: req.Domain, + Settings: req.Settings, + }) + if err != nil && errors.Is(err, domain.ErrAlreadyExists) { + utils.RespondConflict(w, err.Error()) + return + } + utils.HandleCreated(w, r, resp, err) +} + +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + cursorStr := r.URL.Query().Get("cursor") + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 { + limit = n + } + } + + var cursor *string + if cursorStr != "" { + cursor = &cursorStr + } + + resp, err := h.queryBus.Ask(r.Context(), query.ListTenantsQuery{Cursor: cursor, Limit: limit}) + if err != nil { + utils.MapErrorFromRequest(w, r, err) + return + } + listResp := resp.(dto.TenantListResponse) + utils.RespondCursorPaginated(w, listResp.Tenants, listResp.NextCursor, listResp.PrevCursor, listResp.HasNext, listResp.HasPrev, listResp.Limit) +} + +func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + utils.RespondBadRequest(w, "invalid tenant ID") + return + } + resp, err := h.queryBus.Ask(r.Context(), query.GetTenantQuery{ID: id}) + if err != nil && errors.Is(err, domain.ErrNotFound) { + utils.RespondNotFound(w, "tenant not found") + return + } + utils.Handle(w, r, resp, err) +} + +func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + utils.RespondBadRequest(w, "invalid tenant ID") + return + } + var req dto.UpdateTenantRequest + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondBadRequest(w, "invalid request body") + return + } + resp, err := h.commandBus.Dispatch(r.Context(), command.UpdateTenantCommand{ + ID: id, + Name: req.Name, + Domain: req.Domain, + Settings: req.Settings, + IsActive: req.IsActive, + }) + if err != nil && errors.Is(err, domain.ErrNotFound) { + utils.RespondNotFound(w, "tenant not found") + return + } + utils.Handle(w, r, resp, err) +} + +func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + utils.RespondBadRequest(w, "invalid tenant ID") + return + } + _, err = h.commandBus.Dispatch(r.Context(), command.DeleteTenantCommand{ID: id}) + if err != nil && errors.Is(err, domain.ErrNotFound) { + utils.RespondNotFound(w, "tenant not found") + return + } + utils.HandleNoContent(w, r, err) +} diff --git a/internal/tenant/interfaces/http/routes.go b/internal/tenant/interfaces/http/routes.go new file mode 100644 index 0000000..ef683e3 --- /dev/null +++ b/internal/tenant/interfaces/http/routes.go @@ -0,0 +1,38 @@ +package http + +import ( + "net/http" + + "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" + "github.com/go-chi/chi/v5" +) + +func NewRouter(handler *Handler, authMiddleware func(http.Handler) http.Handler, authorizer middleware.Authorizer) *chi.Mux { + r := chi.NewRouter() + + r.Group(func(r chi.Router) { + r.Use(authMiddleware) + r.Group(func(r chi.Router) { + r.Use(middleware.Authorization(authorizer, "tenant", "create")) + r.Post("/", handler.Create) + }) + r.Group(func(r chi.Router) { + r.Use(middleware.Authorization(authorizer, "tenant", "list")) + r.Get("/", handler.List) + }) + r.Group(func(r chi.Router) { + r.Use(middleware.Authorization(authorizer, "tenant", "read")) + r.Get("/{id}", handler.GetByID) + }) + r.Group(func(r chi.Router) { + r.Use(middleware.Authorization(authorizer, "tenant", "update")) + r.Put("/{id}", handler.Update) + }) + r.Group(func(r chi.Router) { + r.Use(middleware.Authorization(authorizer, "tenant", "delete")) + r.Delete("/{id}", handler.Delete) + }) + }) + + return r +} diff --git a/internal/tenant/module.go b/internal/tenant/module.go new file mode 100644 index 0000000..5177e9e --- /dev/null +++ b/internal/tenant/module.go @@ -0,0 +1,33 @@ +package tenant + +import ( + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/command" + "github.com/IDTS-LAB/go-codebase/internal/tenant/application/query" + "github.com/IDTS-LAB/go-codebase/internal/tenant/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/tenant/infrastructure/persistence" + httpHandler "github.com/IDTS-LAB/go-codebase/internal/tenant/interfaces/http" + "go.uber.org/fx" +) + +var Module = fx.Module("tenant", + fx.Provide( + persistence.NewTenantRepository, + httpHandler.NewHandler, + ), + + fx.Invoke(registerHandlers), +) + +func registerHandlers( + commandBus cqrs.CommandBus, + queryBus cqrs.QueryBus, + repo repository.TenantRepository, +) { + commandBus.Register(command.CreateTenantCommand{}, command.NewCreateTenantHandler(repo)) + commandBus.Register(command.UpdateTenantCommand{}, command.NewUpdateTenantHandler(repo)) + commandBus.Register(command.DeleteTenantCommand{}, command.NewDeleteTenantHandler(repo)) + + queryBus.Register(query.GetTenantQuery{}, query.NewGetTenantHandler(repo)) + queryBus.Register(query.ListTenantsQuery{}, query.NewListTenantsHandler(repo)) +} diff --git a/internal/todo/application/command/complete_todo.go b/internal/todo/application/command/complete_todo.go index 1dca110..72c34b3 100644 --- a/internal/todo/application/command/complete_todo.go +++ b/internal/todo/application/command/complete_todo.go @@ -3,8 +3,9 @@ package command import ( "context" - "github.com/IDTS-LAB/go-codebase/internal/todo/application/dto" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" "github.com/IDTS-LAB/go-codebase/internal/todo/application/mapper" + "github.com/IDTS-LAB/go-codebase/internal/todo/domain/event" "github.com/IDTS-LAB/go-codebase/internal/todo/domain/service" "github.com/google/uuid" ) @@ -15,16 +16,28 @@ type CompleteTodoCommand struct { type CompleteTodoHandler struct { domainSvc *service.TodoDomainService + eventBus events.EventBus } -func NewCompleteTodoHandler(domainSvc *service.TodoDomainService) *CompleteTodoHandler { - return &CompleteTodoHandler{domainSvc: domainSvc} +func NewCompleteTodoHandler(domainSvc *service.TodoDomainService, eventBus events.EventBus) *CompleteTodoHandler { + return &CompleteTodoHandler{domainSvc: domainSvc, eventBus: eventBus} } -func (h *CompleteTodoHandler) Handle(ctx context.Context, cmd CompleteTodoCommand) (dto.TodoResponse, error) { - todo, err := h.domainSvc.CompleteTodo(ctx, cmd.ID) +func (h *CompleteTodoHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(CompleteTodoCommand) + todo, err := h.domainSvc.CompleteTodo(ctx, c.ID) if err != nil { - return dto.TodoResponse{}, err + return nil, err } + + _ = h.eventBus.Publish(ctx, events.Event{ + Type: event.TodoCompletedEvent, + Payload: event.TodoCompleted{ + ID: todo.ID, + Title: todo.Title, + UpdatedAt: todo.UpdatedAt, + }, + }) + return mapper.ToTodoResponse(todo), nil } diff --git a/internal/todo/application/command/create_todo.go b/internal/todo/application/command/create_todo.go index 8a97e0b..4f6cfe1 100644 --- a/internal/todo/application/command/create_todo.go +++ b/internal/todo/application/command/create_todo.go @@ -3,8 +3,9 @@ package command import ( "context" - "github.com/IDTS-LAB/go-codebase/internal/todo/application/dto" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" "github.com/IDTS-LAB/go-codebase/internal/todo/application/mapper" + "github.com/IDTS-LAB/go-codebase/internal/todo/domain/event" "github.com/IDTS-LAB/go-codebase/internal/todo/domain/service" ) @@ -15,16 +16,28 @@ type CreateTodoCommand struct { type CreateTodoHandler struct { domainSvc *service.TodoDomainService + eventBus events.EventBus } -func NewCreateTodoHandler(domainSvc *service.TodoDomainService) *CreateTodoHandler { - return &CreateTodoHandler{domainSvc: domainSvc} +func NewCreateTodoHandler(domainSvc *service.TodoDomainService, eventBus events.EventBus) *CreateTodoHandler { + return &CreateTodoHandler{domainSvc: domainSvc, eventBus: eventBus} } -func (h *CreateTodoHandler) Handle(ctx context.Context, cmd CreateTodoCommand) (dto.TodoResponse, error) { - todo, err := h.domainSvc.CreateTodo(ctx, cmd.Title, cmd.Description) +func (h *CreateTodoHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(CreateTodoCommand) + todo, err := h.domainSvc.CreateTodo(ctx, c.Title, c.Description) if err != nil { - return dto.TodoResponse{}, err + return nil, err } + + _ = h.eventBus.Publish(ctx, events.Event{ + Type: event.TodoCreatedEvent, + Payload: event.TodoCreated{ + ID: todo.ID, + Title: todo.Title, + CreatedAt: todo.CreatedAt, + }, + }) + return mapper.ToTodoResponse(todo), nil } diff --git a/internal/todo/application/command/delete_todo.go b/internal/todo/application/command/delete_todo.go index d5b8bc1..23313b2 100644 --- a/internal/todo/application/command/delete_todo.go +++ b/internal/todo/application/command/delete_todo.go @@ -2,7 +2,10 @@ package command import ( "context" + "time" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" + "github.com/IDTS-LAB/go-codebase/internal/todo/domain/event" "github.com/IDTS-LAB/go-codebase/internal/todo/domain/service" "github.com/google/uuid" ) @@ -13,12 +16,26 @@ type DeleteTodoCommand struct { type DeleteTodoHandler struct { domainSvc *service.TodoDomainService + eventBus events.EventBus } -func NewDeleteTodoHandler(domainSvc *service.TodoDomainService) *DeleteTodoHandler { - return &DeleteTodoHandler{domainSvc: domainSvc} +func NewDeleteTodoHandler(domainSvc *service.TodoDomainService, eventBus events.EventBus) *DeleteTodoHandler { + return &DeleteTodoHandler{domainSvc: domainSvc, eventBus: eventBus} } -func (h *DeleteTodoHandler) Handle(ctx context.Context, cmd DeleteTodoCommand) error { - return h.domainSvc.DeleteTodo(ctx, cmd.ID) +func (h *DeleteTodoHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(DeleteTodoCommand) + if err := h.domainSvc.DeleteTodo(ctx, c.ID); err != nil { + return nil, err + } + + _ = h.eventBus.Publish(ctx, events.Event{ + Type: event.TodoDeletedEvent, + Payload: event.TodoDeleted{ + ID: c.ID, + DeletedAt: time.Now().UTC(), + }, + }) + + return nil, nil } diff --git a/internal/todo/application/command/update_todo.go b/internal/todo/application/command/update_todo.go index e99dfe4..827cbb1 100644 --- a/internal/todo/application/command/update_todo.go +++ b/internal/todo/application/command/update_todo.go @@ -3,8 +3,9 @@ package command import ( "context" - "github.com/IDTS-LAB/go-codebase/internal/todo/application/dto" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" "github.com/IDTS-LAB/go-codebase/internal/todo/application/mapper" + "github.com/IDTS-LAB/go-codebase/internal/todo/domain/event" "github.com/IDTS-LAB/go-codebase/internal/todo/domain/service" "github.com/google/uuid" ) @@ -17,16 +18,28 @@ type UpdateTodoCommand struct { type UpdateTodoHandler struct { domainSvc *service.TodoDomainService + eventBus events.EventBus } -func NewUpdateTodoHandler(domainSvc *service.TodoDomainService) *UpdateTodoHandler { - return &UpdateTodoHandler{domainSvc: domainSvc} +func NewUpdateTodoHandler(domainSvc *service.TodoDomainService, eventBus events.EventBus) *UpdateTodoHandler { + return &UpdateTodoHandler{domainSvc: domainSvc, eventBus: eventBus} } -func (h *UpdateTodoHandler) Handle(ctx context.Context, cmd UpdateTodoCommand) (dto.TodoResponse, error) { - todo, err := h.domainSvc.UpdateTodo(ctx, cmd.ID, cmd.Title, cmd.Description) +func (h *UpdateTodoHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(UpdateTodoCommand) + todo, err := h.domainSvc.UpdateTodo(ctx, c.ID, c.Title, c.Description) if err != nil { - return dto.TodoResponse{}, err + return nil, err } + + _ = h.eventBus.Publish(ctx, events.Event{ + Type: event.TodoUpdatedEvent, + Payload: event.TodoUpdated{ + ID: todo.ID, + Title: todo.Title, + UpdatedAt: todo.UpdatedAt, + }, + }) + return mapper.ToTodoResponse(todo), nil } diff --git a/internal/todo/application/dto/todo_dto.go b/internal/todo/application/dto/todo_dto.go index f131c30..a579b42 100644 --- a/internal/todo/application/dto/todo_dto.go +++ b/internal/todo/application/dto/todo_dto.go @@ -17,12 +17,12 @@ type UpdateTodoRequest struct { } type TodoResponse struct { - ID uuid.UUID `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - Completed bool `json:"completed"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Completed bool `json:"completed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type TodoListResponse struct { diff --git a/internal/todo/application/query/get_todo.go b/internal/todo/application/query/get_todo.go index e858224..a6df42c 100644 --- a/internal/todo/application/query/get_todo.go +++ b/internal/todo/application/query/get_todo.go @@ -3,7 +3,6 @@ package query import ( "context" - "github.com/IDTS-LAB/go-codebase/internal/todo/application/dto" "github.com/IDTS-LAB/go-codebase/internal/todo/application/mapper" "github.com/IDTS-LAB/go-codebase/internal/todo/domain/service" "github.com/google/uuid" @@ -21,10 +20,11 @@ func NewGetTodoHandler(domainSvc *service.TodoDomainService) *GetTodoHandler { return &GetTodoHandler{domainSvc: domainSvc} } -func (h *GetTodoHandler) Handle(ctx context.Context, q GetTodoQuery) (dto.TodoResponse, error) { - todo, err := h.domainSvc.GetTodo(ctx, q.ID) +func (h *GetTodoHandler) Handle(ctx context.Context, q any) (any, error) { + query := q.(GetTodoQuery) + todo, err := h.domainSvc.GetTodo(ctx, query.ID) if err != nil { - return dto.TodoResponse{}, err + return nil, err } return mapper.ToTodoResponse(todo), nil } diff --git a/internal/todo/application/query/list_todos.go b/internal/todo/application/query/list_todos.go index 891b65e..c971dd4 100644 --- a/internal/todo/application/query/list_todos.go +++ b/internal/todo/application/query/list_todos.go @@ -3,14 +3,22 @@ package query import ( "context" - "github.com/IDTS-LAB/go-codebase/internal/todo/application/dto" - "github.com/IDTS-LAB/go-codebase/internal/todo/application/mapper" + "github.com/IDTS-LAB/go-codebase/internal/todo/domain/entity" "github.com/IDTS-LAB/go-codebase/internal/todo/domain/service" ) type ListTodosQuery struct { - Page int - PerPage int + Cursor *string + Limit int +} + +type ListTodosResult struct { + Todos []*entity.Todo + NextCursor *string + PrevCursor *string + HasNext bool + HasPrev bool + Limit int } type ListTodosHandler struct { @@ -21,11 +29,18 @@ func NewListTodosHandler(domainSvc *service.TodoDomainService) *ListTodosHandler return &ListTodosHandler{domainSvc: domainSvc} } -func (h *ListTodosHandler) Handle(ctx context.Context, q ListTodosQuery) (dto.TodoListResponse, error) { - offset := (q.Page - 1) * q.PerPage - todos, total, err := h.domainSvc.ListTodos(ctx, offset, q.PerPage) +func (h *ListTodosHandler) Handle(ctx context.Context, q any) (any, error) { + query := q.(ListTodosQuery) + todos, nextCursor, prevCursor, hasNext, hasPrev, err := h.domainSvc.ListTodos(ctx, query.Cursor, query.Limit) if err != nil { - return dto.TodoListResponse{}, err + return nil, err } - return mapper.ToTodoListResponse(todos, total, q.Page, q.PerPage), nil + return ListTodosResult{ + Todos: todos, + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + Limit: query.Limit, + }, nil } diff --git a/internal/todo/application/query/search_todos.go b/internal/todo/application/query/search_todos.go index bb43c26..7d0793a 100644 --- a/internal/todo/application/query/search_todos.go +++ b/internal/todo/application/query/search_todos.go @@ -3,15 +3,23 @@ package query import ( "context" - "github.com/IDTS-LAB/go-codebase/internal/todo/application/dto" - "github.com/IDTS-LAB/go-codebase/internal/todo/application/mapper" + "github.com/IDTS-LAB/go-codebase/internal/todo/domain/entity" "github.com/IDTS-LAB/go-codebase/internal/todo/domain/service" ) type SearchTodosQuery struct { - Query string - Page int - PerPage int + Query string + Cursor *string + Limit int +} + +type SearchTodosResult struct { + Todos []*entity.Todo + NextCursor *string + PrevCursor *string + HasNext bool + HasPrev bool + Limit int } type SearchTodosHandler struct { @@ -22,11 +30,18 @@ func NewSearchTodosHandler(domainSvc *service.TodoDomainService) *SearchTodosHan return &SearchTodosHandler{domainSvc: domainSvc} } -func (h *SearchTodosHandler) Handle(ctx context.Context, q SearchTodosQuery) (dto.TodoListResponse, error) { - offset := (q.Page - 1) * q.PerPage - todos, total, err := h.domainSvc.SearchTodos(ctx, q.Query, offset, q.PerPage) +func (h *SearchTodosHandler) Handle(ctx context.Context, q any) (any, error) { + query := q.(SearchTodosQuery) + todos, nextCursor, prevCursor, hasNext, hasPrev, err := h.domainSvc.SearchTodos(ctx, query.Query, query.Cursor, query.Limit) if err != nil { - return dto.TodoListResponse{}, err + return nil, err } - return mapper.ToTodoListResponse(todos, total, q.Page, q.PerPage), nil + return SearchTodosResult{ + Todos: todos, + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + Limit: query.Limit, + }, nil } diff --git a/internal/todo/application/service/todo_app_service.go b/internal/todo/application/service/todo_app_service.go deleted file mode 100644 index 1341c10..0000000 --- a/internal/todo/application/service/todo_app_service.go +++ /dev/null @@ -1,75 +0,0 @@ -package service - -import ( - "context" - - "github.com/IDTS-LAB/go-codebase/internal/todo/application/command" - "github.com/IDTS-LAB/go-codebase/internal/todo/application/dto" - "github.com/IDTS-LAB/go-codebase/internal/todo/application/query" - "github.com/google/uuid" -) - -type TodoAppService struct { - createHandler *command.CreateTodoHandler - updateHandler *command.UpdateTodoHandler - deleteHandler *command.DeleteTodoHandler - completeHandler *command.CompleteTodoHandler - getHandler *query.GetTodoHandler - listHandler *query.ListTodosHandler - searchHandler *query.SearchTodosHandler -} - -func NewTodoAppService( - createHandler *command.CreateTodoHandler, - updateHandler *command.UpdateTodoHandler, - deleteHandler *command.DeleteTodoHandler, - completeHandler *command.CompleteTodoHandler, - getHandler *query.GetTodoHandler, - listHandler *query.ListTodosHandler, - searchHandler *query.SearchTodosHandler, -) *TodoAppService { - return &TodoAppService{ - createHandler: createHandler, - updateHandler: updateHandler, - deleteHandler: deleteHandler, - completeHandler: completeHandler, - getHandler: getHandler, - listHandler: listHandler, - searchHandler: searchHandler, - } -} - -func (s *TodoAppService) CreateTodo(ctx context.Context, req dto.CreateTodoRequest) (dto.TodoResponse, error) { - return s.createHandler.Handle(ctx, command.CreateTodoCommand{ - Title: req.Title, - Description: req.Description, - }) -} - -func (s *TodoAppService) GetTodo(ctx context.Context, id uuid.UUID) (dto.TodoResponse, error) { - return s.getHandler.Handle(ctx, query.GetTodoQuery{ID: id}) -} - -func (s *TodoAppService) ListTodos(ctx context.Context, page, perPage int) (dto.TodoListResponse, error) { - return s.listHandler.Handle(ctx, query.ListTodosQuery{Page: page, PerPage: perPage}) -} - -func (s *TodoAppService) UpdateTodo(ctx context.Context, id uuid.UUID, req dto.UpdateTodoRequest) (dto.TodoResponse, error) { - return s.updateHandler.Handle(ctx, command.UpdateTodoCommand{ - ID: id, - Title: req.Title, - Description: req.Description, - }) -} - -func (s *TodoAppService) DeleteTodo(ctx context.Context, id uuid.UUID) error { - return s.deleteHandler.Handle(ctx, command.DeleteTodoCommand{ID: id}) -} - -func (s *TodoAppService) CompleteTodo(ctx context.Context, id uuid.UUID) (dto.TodoResponse, error) { - return s.completeHandler.Handle(ctx, command.CompleteTodoCommand{ID: id}) -} - -func (s *TodoAppService) SearchTodos(ctx context.Context, queryStr string, page, perPage int) (dto.TodoListResponse, error) { - return s.searchHandler.Handle(ctx, query.SearchTodosQuery{Query: queryStr, Page: page, PerPage: perPage}) -} diff --git a/internal/todo/domain/repository/todo_repository.go b/internal/todo/domain/repository/todo_repository.go index 6f00f74..13372c3 100644 --- a/internal/todo/domain/repository/todo_repository.go +++ b/internal/todo/domain/repository/todo_repository.go @@ -10,8 +10,8 @@ import ( type TodoRepository interface { Create(ctx context.Context, todo *entity.Todo) error GetByID(ctx context.Context, id uuid.UUID) (*entity.Todo, error) - GetAll(ctx context.Context, offset, limit int) ([]*entity.Todo, int, error) + GetAll(ctx context.Context, cursor *string, limit int) ([]*entity.Todo, *string, *string, bool, bool, error) Update(ctx context.Context, todo *entity.Todo) error Delete(ctx context.Context, id uuid.UUID) error - Search(ctx context.Context, query string, offset, limit int) ([]*entity.Todo, int, error) + Search(ctx context.Context, query string, cursor *string, limit int) ([]*entity.Todo, *string, *string, bool, bool, error) } diff --git a/internal/todo/domain/service/todo_domain_service.go b/internal/todo/domain/service/todo_domain_service.go index 3ee2089..197aa6d 100644 --- a/internal/todo/domain/service/todo_domain_service.go +++ b/internal/todo/domain/service/todo_domain_service.go @@ -43,8 +43,8 @@ func (s *TodoDomainService) GetTodo(ctx context.Context, id uuid.UUID) (*entity. return todo, nil } -func (s *TodoDomainService) ListTodos(ctx context.Context, offset, limit int) ([]*entity.Todo, int, error) { - return s.repo.GetAll(ctx, offset, limit) +func (s *TodoDomainService) ListTodos(ctx context.Context, cursor *string, limit int) ([]*entity.Todo, *string, *string, bool, bool, error) { + return s.repo.GetAll(ctx, cursor, limit) } func (s *TodoDomainService) UpdateTodo(ctx context.Context, id uuid.UUID, title, description string) (*entity.Todo, error) { @@ -85,6 +85,6 @@ func (s *TodoDomainService) CompleteTodo(ctx context.Context, id uuid.UUID) (*en return todo, nil } -func (s *TodoDomainService) SearchTodos(ctx context.Context, query string, offset, limit int) ([]*entity.Todo, int, error) { - return s.repo.Search(ctx, query, offset, limit) +func (s *TodoDomainService) SearchTodos(ctx context.Context, query string, cursor *string, limit int) ([]*entity.Todo, *string, *string, bool, bool, error) { + return s.repo.Search(ctx, query, cursor, limit) } diff --git a/internal/todo/infrastructure/eventbus/todo_event_handler.go b/internal/todo/infrastructure/eventbus/todo_event_handler.go index e6e7738..273b2cd 100644 --- a/internal/todo/infrastructure/eventbus/todo_event_handler.go +++ b/internal/todo/infrastructure/eventbus/todo_event_handler.go @@ -16,7 +16,7 @@ func NewTodoEventHandler(log domain.Logger) *TodoEventHandler { return &TodoEventHandler{log: log} } -func (h *TodoEventHandler) Register(bus *events.EventBus) { +func (h *TodoEventHandler) Register(bus events.EventBus) { bus.Subscribe(event.TodoCreatedEvent, h.onCreated) bus.Subscribe(event.TodoUpdatedEvent, h.onUpdated) bus.Subscribe(event.TodoCompletedEvent, h.onCompleted) diff --git a/internal/todo/infrastructure/persistence/queries.sql b/internal/todo/infrastructure/persistence/queries.sql deleted file mode 100644 index f79f7a5..0000000 --- a/internal/todo/infrastructure/persistence/queries.sql +++ /dev/null @@ -1,39 +0,0 @@ --- name: CreateTodo :exec -INSERT INTO todos (id, title, description, completed, created_at, updated_at) -VALUES ($1, $2, $3, $4, $5, $6); - --- name: GetTodoByID :one -SELECT id, title, description, completed, created_at, updated_at, deleted_at -FROM todos -WHERE id = $1 AND deleted_at IS NULL; - --- name: ListTodos :many -SELECT id, title, description, completed, created_at, updated_at, deleted_at -FROM todos -WHERE deleted_at IS NULL -ORDER BY created_at DESC -LIMIT $1 OFFSET $2; - --- name: CountTodos :one -SELECT COUNT(*) FROM todos WHERE deleted_at IS NULL; - --- name: UpdateTodo :exec -UPDATE todos -SET title = $2, description = $3, completed = $4, updated_at = $5 -WHERE id = $1 AND deleted_at IS NULL; - --- name: SoftDeleteTodo :exec -UPDATE todos -SET deleted_at = NOW(), updated_at = NOW() -WHERE id = $1 AND deleted_at IS NULL; - --- name: SearchTodos :many -SELECT id, title, description, completed, created_at, updated_at, deleted_at -FROM todos -WHERE deleted_at IS NULL AND (title ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%') -ORDER BY created_at DESC -LIMIT $2 OFFSET $3; - --- name: CountSearchTodos :one -SELECT COUNT(*) FROM todos -WHERE deleted_at IS NULL AND (title ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%'); diff --git a/internal/todo/infrastructure/persistence/queries/todo.sql b/internal/todo/infrastructure/persistence/queries/todo.sql new file mode 100644 index 0000000..f3e2932 --- /dev/null +++ b/internal/todo/infrastructure/persistence/queries/todo.sql @@ -0,0 +1,18 @@ +-- name: CreateTodo :exec +INSERT INTO todos (id, title, description, completed, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6); + +-- name: GetTodoByID :one +SELECT id, title, description, completed, created_at, updated_at, deleted_at +FROM todos +WHERE id = $1 AND deleted_at IS NULL; + +-- name: UpdateTodo :execrows +UPDATE todos +SET title = $2, description = $3, completed = $4, updated_at = $5 +WHERE id = $1 AND deleted_at IS NULL; + +-- name: SoftDeleteTodo :execrows +UPDATE todos +SET deleted_at = NOW(), updated_at = NOW() +WHERE id = $1 AND deleted_at IS NULL; diff --git a/internal/todo/infrastructure/persistence/sqlc/db.go b/internal/todo/infrastructure/persistence/sqlc/db.go new file mode 100644 index 0000000..5922bc9 --- /dev/null +++ b/internal/todo/infrastructure/persistence/sqlc/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/todo/infrastructure/persistence/sqlc/models.go b/internal/todo/infrastructure/persistence/sqlc/models.go new file mode 100644 index 0000000..4a7067e --- /dev/null +++ b/internal/todo/infrastructure/persistence/sqlc/models.go @@ -0,0 +1,225 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "database/sql" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type AuditLog struct { + ID uuid.UUID `json:"id"` + RequestID string `json:"request_id"` + UserID uuid.NullUUID `json:"user_id"` + UserEmail sql.NullString `json:"user_email"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int32 `json:"status_code"` + DurationMs int64 `json:"duration_ms"` + Ip string `json:"ip"` + UserAgent string `json:"user_agent"` + RequestBody sql.NullString `json:"request_body"` + ResponseSize int32 `json:"response_size"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type CasbinRule struct { + ID int32 `json:"id"` + Ptype string `json:"ptype"` + V0 sql.NullString `json:"v0"` + V1 sql.NullString `json:"v1"` + V2 sql.NullString `json:"v2"` + V3 sql.NullString `json:"v3"` + V4 sql.NullString `json:"v4"` + V5 sql.NullString `json:"v5"` +} + +type ErrorLog struct { + ID uuid.UUID `json:"id"` + RequestID string `json:"request_id"` + UserID uuid.NullUUID `json:"user_id"` + UserEmail sql.NullString `json:"user_email"` + Level string `json:"level"` + Message string `json:"message"` + Error string `json:"error"` + StackTrace string `json:"stack_trace"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int32 `json:"status_code"` + Ip string `json:"ip"` + UserAgent string `json:"user_agent"` + RequestBody sql.NullString `json:"request_body"` + Metadata []byte `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type Permission struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Resource string `json:"resource"` + Action string `json:"action"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + TenantID string `json:"tenant_id"` +} + +type RefreshToken struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + RevokedAt sql.NullTime `json:"revoked_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +type Role struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + TenantID string `json:"tenant_id"` +} + +type RolePermission struct { + RoleID uuid.UUID `json:"role_id"` + PermissionID uuid.UUID `json:"permission_id"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type Tenant struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Domain sql.NullString `json:"domain"` + Settings json.RawMessage `json:"settings"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Todo struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Completed bool `json:"completed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + TenantID string `json:"tenant_id"` +} + +type User struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + FailedLoginAttempts int32 `json:"failed_login_attempts"` + EmailVerified sql.NullBool `json:"email_verified"` + EmailVerifyToken sql.NullString `json:"email_verify_token"` + EmailVerifyExpires sql.NullTime `json:"email_verify_expires"` + PasswordResetToken sql.NullString `json:"password_reset_token"` + PasswordResetExpires sql.NullTime `json:"password_reset_expires"` + TenantID string `json:"tenant_id"` + EmailVerifiedAt sql.NullTime `json:"email_verified_at"` +} + +type UserAddress struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Label string `json:"label"` + IsDefault bool `json:"is_default"` + Street string `json:"street"` + City string `json:"city"` + State string `json:"state"` + PostalCode string `json:"postal_code"` + Country string `json:"country"` +} + +type UserCredential struct { + UserID uuid.UUID `json:"user_id"` + PasswordHash string `json:"password_hash"` + LastLoginAt sql.NullTime `json:"last_login_at"` +} + +type UserPreference struct { + UserID uuid.UUID `json:"user_id"` + Preferences json.RawMessage `json:"preferences"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserProfile struct { + UserID uuid.UUID `json:"user_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Phone sql.NullString `json:"phone"` + AvatarUrl sql.NullString `json:"avatar_url"` + Timezone string `json:"timezone"` + Locale string `json:"locale"` + Bio sql.NullString `json:"bio"` +} + +type UserRole struct { + UserID uuid.UUID `json:"user_id"` + RoleID uuid.UUID `json:"role_id"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type UserSecurity struct { + UserID uuid.UUID `json:"user_id"` + LoginAttempts int32 `json:"login_attempts"` + LockedUntil sql.NullTime `json:"locked_until"` + MfaEnabled bool `json:"mfa_enabled"` + MfaSecret sql.NullString `json:"mfa_secret"` +} + +type UserSession struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + RefreshTokenHash sql.NullString `json:"refresh_token_hash"` + DeviceInfo string `json:"device_info"` + IpAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + ExpiresAt sql.NullTime `json:"expires_at"` + LastUsedAt time.Time `json:"last_used_at"` + RevokedAt sql.NullTime `json:"revoked_at"` + CreatedAt time.Time `json:"created_at"` +} + +type UserSocialLink struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Provider string `json:"provider"` + ProviderID string `json:"provider_id"` + ProviderEmail sql.NullString `json:"provider_email"` + AvatarUrl sql.NullString `json:"avatar_url"` + CreatedAt time.Time `json:"created_at"` +} + +type UserToken struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + TokenType string `json:"token_type"` + TokenHash sql.NullString `json:"token_hash"` + ExpiresAt sql.NullTime `json:"expires_at"` + ConsumedAt sql.NullTime `json:"consumed_at"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/todo/infrastructure/persistence/sqlc/todo.sql.go b/internal/todo/infrastructure/persistence/sqlc/todo.sql.go new file mode 100644 index 0000000..adfe72e --- /dev/null +++ b/internal/todo/infrastructure/persistence/sqlc/todo.sql.go @@ -0,0 +1,113 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: todo.sql + +package sqlc + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" +) + +const createTodo = `-- name: CreateTodo :exec +INSERT INTO todos (id, title, description, completed, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6) +` + +type CreateTodoParams struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Completed bool `json:"completed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (q *Queries) CreateTodo(ctx context.Context, arg CreateTodoParams) error { + _, err := q.db.ExecContext(ctx, createTodo, + arg.ID, + arg.Title, + arg.Description, + arg.Completed, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const getTodoByID = `-- name: GetTodoByID :one +SELECT id, title, description, completed, created_at, updated_at, deleted_at +FROM todos +WHERE id = $1 AND deleted_at IS NULL +` + +type GetTodoByIDRow struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Completed bool `json:"completed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +func (q *Queries) GetTodoByID(ctx context.Context, id uuid.UUID) (GetTodoByIDRow, error) { + row := q.db.QueryRowContext(ctx, getTodoByID, id) + var i GetTodoByIDRow + err := row.Scan( + &i.ID, + &i.Title, + &i.Description, + &i.Completed, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const softDeleteTodo = `-- name: SoftDeleteTodo :execrows +UPDATE todos +SET deleted_at = NOW(), updated_at = NOW() +WHERE id = $1 AND deleted_at IS NULL +` + +func (q *Queries) SoftDeleteTodo(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.ExecContext(ctx, softDeleteTodo, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const updateTodo = `-- name: UpdateTodo :execrows +UPDATE todos +SET title = $2, description = $3, completed = $4, updated_at = $5 +WHERE id = $1 AND deleted_at IS NULL +` + +type UpdateTodoParams struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Completed bool `json:"completed"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (q *Queries) UpdateTodo(ctx context.Context, arg UpdateTodoParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateTodo, + arg.ID, + arg.Title, + arg.Description, + arg.Completed, + arg.UpdatedAt, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/todo/infrastructure/persistence/todo_repository.go b/internal/todo/infrastructure/persistence/todo_repository.go index 84b79d6..1ebc007 100644 --- a/internal/todo/infrastructure/persistence/todo_repository.go +++ b/internal/todo/infrastructure/persistence/todo_repository.go @@ -4,33 +4,37 @@ import ( "context" "database/sql" "fmt" + "strings" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/cursor" + "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" + "github.com/IDTS-LAB/go-codebase/internal/shared/tenantfilter" "github.com/IDTS-LAB/go-codebase/internal/todo/domain/entity" "github.com/IDTS-LAB/go-codebase/internal/todo/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/todo/infrastructure/persistence/sqlc" "github.com/google/uuid" ) type todoRepository struct { - db *sql.DB + db *sql.DB + tenantConfig *tenantfilter.Config } -func NewTodoRepository(db *sql.DB) repository.TodoRepository { - return &todoRepository{db: db} +func NewTodoRepository(db *sql.DB, tenantConfig *tenantfilter.Config) repository.TodoRepository { + return &todoRepository{db: db, tenantConfig: tenantConfig} } func (r *todoRepository) Create(ctx context.Context, todo *entity.Todo) error { - query := ` - INSERT INTO todos (id, title, description, completed, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6)` - - _, err := r.db.ExecContext(ctx, query, - todo.ID, - todo.Title, - todo.Description, - todo.Completed, - todo.CreatedAt, - todo.UpdatedAt, - ) + q := sqlc.New(r.db) + err := q.CreateTodo(ctx, sqlc.CreateTodoParams{ + ID: todo.ID, + Title: todo.Title, + Description: todo.Description, + Completed: todo.Completed, + CreatedAt: todo.CreatedAt, + UpdatedAt: todo.UpdatedAt, + }) if err != nil { return fmt.Errorf("insert todo: %w", err) } @@ -38,90 +42,111 @@ func (r *todoRepository) Create(ctx context.Context, todo *entity.Todo) error { } func (r *todoRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.Todo, error) { - query := ` - SELECT id, title, description, completed, created_at, updated_at, deleted_at - FROM todos - WHERE id = $1 AND deleted_at IS NULL` - - todo := &entity.Todo{} - err := r.db.QueryRowContext(ctx, query, id).Scan( - &todo.ID, - &todo.Title, - &todo.Description, - &todo.Completed, - &todo.CreatedAt, - &todo.UpdatedAt, - &todo.DeletedAt, - ) + q := sqlc.New(r.db) + row, err := q.GetTodoByID(ctx, id) if err == sql.ErrNoRows { return nil, fmt.Errorf("todo not found") } if err != nil { return nil, fmt.Errorf("get todo: %w", err) } + todo := &entity.Todo{ + Entity: domain.Entity{ID: row.ID, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, + Title: row.Title, + Description: row.Description, + Completed: row.Completed, + } + if row.DeletedAt.Valid { + todo.DeletedAt = &row.DeletedAt.Time + } return todo, nil } -func (r *todoRepository) GetAll(ctx context.Context, offset, limit int) ([]*entity.Todo, int, error) { - countQuery := `SELECT COUNT(*) FROM todos WHERE deleted_at IS NULL` - var total int - if err := r.db.QueryRowContext(ctx, countQuery).Scan(&total); err != nil { - return nil, 0, fmt.Errorf("count todos: %w", err) +func (r *todoRepository) GetAll(ctx context.Context, cursorArg *string, limit int) ([]*entity.Todo, *string, *string, bool, bool, error) { + args := []interface{}{} + whereClause := "WHERE deleted_at IS NULL" + + if r.tenantConfig != nil && r.tenantConfig.Enabled { + tenantID := middleware.GetTenantID(ctx) + if tenantID != "" { + whereClause += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tenantID) + } + } + + nextPos := len(args) + 1 + if cursorArg != nil { + c, err := cursor.Decode(*cursorArg) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("invalid cursor: %w", err) + } + whereClause += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", nextPos, nextPos+1) + args = append(args, c.Timestamp, c.ID) + nextPos += 2 } - query := ` - SELECT id, title, description, completed, created_at, updated_at, deleted_at - FROM todos - WHERE deleted_at IS NULL - ORDER BY created_at DESC - LIMIT $1 OFFSET $2` + dataQuery := fmt.Sprintf("SELECT id, title, description, completed, created_at, updated_at, deleted_at FROM todos %s ORDER BY created_at DESC, id DESC LIMIT $%d", whereClause, nextPos) + args = append(args, limit+1) - rows, err := r.db.QueryContext(ctx, query, limit, offset) + rows, err := r.db.QueryContext(ctx, dataQuery, args...) if err != nil { - return nil, 0, fmt.Errorf("query todos: %w", err) + return nil, nil, nil, false, false, fmt.Errorf("query todos: %w", err) } defer rows.Close() var todos []*entity.Todo for rows.Next() { - todo := &entity.Todo{} - if err := rows.Scan( - &todo.ID, - &todo.Title, - &todo.Description, - &todo.Completed, - &todo.CreatedAt, - &todo.UpdatedAt, - &todo.DeletedAt, - ); err != nil { - return nil, 0, fmt.Errorf("scan todo: %w", err) + var t entity.Todo + var deletedAt sql.NullTime + if err := rows.Scan(&t.ID, &t.Title, &t.Description, &t.Completed, &t.CreatedAt, &t.UpdatedAt, &deletedAt); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("scan todo: %w", err) + } + if deletedAt.Valid { + t.DeletedAt = &deletedAt.Time } - todos = append(todos, todo) + todos = append(todos, &t) } - return todos, total, nil + if err := rows.Err(); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("rows iteration: %w", err) + } + + hasNext := len(todos) > limit + if hasNext { + todos = todos[:limit] + } + + var nextCursor *string + var prevCursor *string + if len(todos) > 0 { + last := todos[len(todos)-1] + nc := cursor.Encode(last.CreatedAt, last.ID) + nextCursor = &nc + + first := todos[0] + pc := cursor.Encode(first.CreatedAt, first.ID) + prevCursor = &pc + } + + hasPrev := cursorArg != nil + if hasPrev && len(todos) == 0 { + hasPrev = false + } + + return todos, nextCursor, prevCursor, hasNext, hasPrev, nil } func (r *todoRepository) Update(ctx context.Context, todo *entity.Todo) error { - query := ` - UPDATE todos - SET title = $2, description = $3, completed = $4, updated_at = $5 - WHERE id = $1 AND deleted_at IS NULL` - - result, err := r.db.ExecContext(ctx, query, - todo.ID, - todo.Title, - todo.Description, - todo.Completed, - todo.UpdatedAt, - ) + q := sqlc.New(r.db) + rows, err := q.UpdateTodo(ctx, sqlc.UpdateTodoParams{ + ID: todo.ID, + Title: todo.Title, + Description: todo.Description, + Completed: todo.Completed, + UpdatedAt: todo.UpdatedAt, + }) if err != nil { return fmt.Errorf("update todo: %w", err) } - - rows, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("rows affected: %w", err) - } if rows == 0 { return fmt.Errorf("todo not found") } @@ -129,58 +154,90 @@ func (r *todoRepository) Update(ctx context.Context, todo *entity.Todo) error { } func (r *todoRepository) Delete(ctx context.Context, id uuid.UUID) error { - query := `UPDATE todos SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL` - result, err := r.db.ExecContext(ctx, query, id) + q := sqlc.New(r.db) + rows, err := q.SoftDeleteTodo(ctx, id) if err != nil { return fmt.Errorf("delete todo: %w", err) } - rows, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("rows affected: %w", err) - } if rows == 0 { return fmt.Errorf("todo not found") } return nil } -func (r *todoRepository) Search(ctx context.Context, query string, offset, limit int) ([]*entity.Todo, int, error) { - searchPattern := "%" + query + "%" +func (r *todoRepository) Search(ctx context.Context, query string, cursorArg *string, limit int) ([]*entity.Todo, *string, *string, bool, bool, error) { + replacer := strings.NewReplacer(`%`, `\%`, `_`, `\_`) + searchPattern := "%" + replacer.Replace(query) + "%" - countQuery := `SELECT COUNT(*) FROM todos WHERE deleted_at IS NULL AND (title ILIKE $1 OR description ILIKE $1)` - var total int - if err := r.db.QueryRowContext(ctx, countQuery, searchPattern).Scan(&total); err != nil { - return nil, 0, fmt.Errorf("count search results: %w", err) + args := []interface{}{searchPattern} + whereClause := "WHERE deleted_at IS NULL AND (title ILIKE $1 OR description ILIKE $1)" + nextPos := 2 + + if r.tenantConfig != nil && r.tenantConfig.Enabled { + tenantID := middleware.GetTenantID(ctx) + if tenantID != "" { + whereClause += fmt.Sprintf(" AND tenant_id = $%d", nextPos) + args = append(args, tenantID) + nextPos++ + } } - sqlQuery := ` - SELECT id, title, description, completed, created_at, updated_at, deleted_at - FROM todos - WHERE deleted_at IS NULL AND (title ILIKE $1 OR description ILIKE $1) - ORDER BY created_at DESC - LIMIT $2 OFFSET $3` + if cursorArg != nil { + c, err := cursor.Decode(*cursorArg) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("invalid cursor: %w", err) + } + whereClause += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", nextPos, nextPos+1) + args = append(args, c.Timestamp, c.ID) + nextPos += 2 + } - rows, err := r.db.QueryContext(ctx, sqlQuery, searchPattern, limit, offset) + dataQuery := fmt.Sprintf("SELECT id, title, description, completed, created_at, updated_at, deleted_at FROM todos %s ORDER BY created_at DESC, id DESC LIMIT $%d", whereClause, nextPos) + args = append(args, limit+1) + + rows, err := r.db.QueryContext(ctx, dataQuery, args...) if err != nil { - return nil, 0, fmt.Errorf("search todos: %w", err) + return nil, nil, nil, false, false, fmt.Errorf("search todos: %w", err) } defer rows.Close() var todos []*entity.Todo for rows.Next() { - todo := &entity.Todo{} - if err := rows.Scan( - &todo.ID, - &todo.Title, - &todo.Description, - &todo.Completed, - &todo.CreatedAt, - &todo.UpdatedAt, - &todo.DeletedAt, - ); err != nil { - return nil, 0, fmt.Errorf("scan todo: %w", err) + var t entity.Todo + var deletedAt sql.NullTime + if err := rows.Scan(&t.ID, &t.Title, &t.Description, &t.Completed, &t.CreatedAt, &t.UpdatedAt, &deletedAt); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("scan todo: %w", err) + } + if deletedAt.Valid { + t.DeletedAt = &deletedAt.Time } - todos = append(todos, todo) + todos = append(todos, &t) } - return todos, total, nil + if err := rows.Err(); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("rows iteration: %w", err) + } + + hasNext := len(todos) > limit + if hasNext { + todos = todos[:limit] + } + + var nextCursor *string + var prevCursor *string + if len(todos) > 0 { + last := todos[len(todos)-1] + nc := cursor.Encode(last.CreatedAt, last.ID) + nextCursor = &nc + + first := todos[0] + pc := cursor.Encode(first.CreatedAt, first.ID) + prevCursor = &pc + } + + hasPrev := cursorArg != nil + if hasPrev && len(todos) == 0 { + hasPrev = false + } + + return todos, nextCursor, prevCursor, hasNext, hasPrev, nil } diff --git a/internal/todo/interfaces/http/handlers.go b/internal/todo/interfaces/http/handlers.go index ef4de58..431831b 100644 --- a/internal/todo/interfaces/http/handlers.go +++ b/internal/todo/interfaces/http/handlers.go @@ -2,25 +2,29 @@ package http import ( "encoding/json" - "fmt" + "errors" "net/http" + "strconv" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" "github.com/IDTS-LAB/go-codebase/internal/shared/utils" "github.com/IDTS-LAB/go-codebase/internal/shared/validator" - appService "github.com/IDTS-LAB/go-codebase/internal/todo/application/service" + "github.com/IDTS-LAB/go-codebase/internal/todo/application/command" "github.com/IDTS-LAB/go-codebase/internal/todo/application/dto" + "github.com/IDTS-LAB/go-codebase/internal/todo/application/query" "github.com/IDTS-LAB/go-codebase/internal/todo/domain/service" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) type Handler struct { - appService *appService.TodoAppService + commandBus cqrs.CommandBus + queryBus cqrs.QueryBus validator *validator.Validator } -func NewHandler(appService *appService.TodoAppService, v *validator.Validator) *Handler { - return &Handler{appService: appService, validator: v} +func NewHandler(commandBus cqrs.CommandBus, queryBus cqrs.QueryBus, v *validator.Validator) *Handler { + return &Handler{commandBus: commandBus, queryBus: queryBus, validator: v} } // CreateTodo godoc @@ -30,9 +34,9 @@ func NewHandler(appService *appService.TodoAppService, v *validator.Validator) * // @Accept json // @Produce json // @Param request body dto.CreateTodoRequest true "Todo to create" -// @Success 201 {object} utils.SuccessResponse{data=dto.TodoResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 500 {object} utils.ErrorResponse +// @Success 201 {object} utils.APIResponse{data=dto.TodoResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 500 {object} utils.APIResponse // @Security BearerAuth // @Router /todos [post] func (h *Handler) CreateTodo(w http.ResponseWriter, r *http.Request) { @@ -45,17 +49,17 @@ func (h *Handler) CreateTodo(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, err.Error()) return } - resp, err := h.appService.CreateTodo(r.Context(), req) + resp, err := h.commandBus.Dispatch(r.Context(), command.CreateTodoCommand{ + Title: req.Title, + Description: req.Description, + }) if err != nil { - switch err { - case service.ErrInvalidTitle: + if errors.Is(err, service.ErrInvalidTitle) { utils.RespondBadRequest(w, err.Error()) - default: - utils.RespondInternalError(w, "failed to create todo") + return } - return } - utils.RespondCreated(w, resp) + utils.HandleCreated(w, r, resp, err) } // ListTodos godoc @@ -65,28 +69,31 @@ func (h *Handler) CreateTodo(w http.ResponseWriter, r *http.Request) { // @Produce json // @Param page query int false "Page number" default(1) // @Param per_page query int false "Items per page" default(20) -// @Success 200 {object} utils.SuccessResponse{data=dto.TodoListResponse} -// @Failure 500 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.TodoListResponse} +// @Failure 500 {object} utils.APIResponse // @Security BearerAuth // @Router /todos [get] func (h *Handler) ListTodos(w http.ResponseWriter, r *http.Request) { - page := 1 - perPage := 20 - if p := r.URL.Query().Get("page"); p != "" { - fmt.Sscanf(p, "%d", &page) - } - if pp := r.URL.Query().Get("per_page"); pp != "" { - fmt.Sscanf(pp, "%d", &perPage) + cursorStr := r.URL.Query().Get("cursor") + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 { + limit = n + } } - if perPage > 100 { - perPage = 100 + + var cursor *string + if cursorStr != "" { + cursor = &cursorStr } - resp, err := h.appService.ListTodos(r.Context(), page, perPage) + + resp, err := h.queryBus.Ask(r.Context(), query.ListTodosQuery{Cursor: cursor, Limit: limit}) if err != nil { - utils.RespondInternalError(w, "failed to list todos") + utils.MapErrorFromRequest(w, r, err) return } - utils.RespondSuccess(w, resp) + result := resp.(query.ListTodosResult) + utils.RespondCursorPaginated(w, result.Todos, result.NextCursor, result.PrevCursor, result.HasNext, result.HasPrev, result.Limit) } // GetTodo godoc @@ -95,9 +102,9 @@ func (h *Handler) ListTodos(w http.ResponseWriter, r *http.Request) { // @Tags todos // @Produce json // @Param id path string true "Todo ID" -// @Success 200 {object} utils.SuccessResponse{data=dto.TodoResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 404 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.TodoResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 404 {object} utils.APIResponse // @Security BearerAuth // @Router /todos/{id} [get] func (h *Handler) GetTodo(w http.ResponseWriter, r *http.Request) { @@ -106,14 +113,13 @@ func (h *Handler) GetTodo(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, "invalid todo ID") return } - resp, err := h.appService.GetTodo(r.Context(), id) + resp, err := h.queryBus.Ask(r.Context(), query.GetTodoQuery{ID: id}) if err != nil { - switch err { - case service.ErrTodoNotFound: + if errors.Is(err, service.ErrTodoNotFound) { utils.RespondNotFound(w, "todo not found") - default: - utils.RespondInternalError(w, "failed to get todo") + return } + utils.Handle(w, r, nil, err) return } utils.RespondSuccess(w, resp) @@ -127,9 +133,9 @@ func (h *Handler) GetTodo(w http.ResponseWriter, r *http.Request) { // @Produce json // @Param id path string true "Todo ID" // @Param request body dto.UpdateTodoRequest true "Fields to update" -// @Success 200 {object} utils.SuccessResponse{data=dto.TodoResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 404 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.TodoResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 404 {object} utils.APIResponse // @Security BearerAuth // @Router /todos/{id} [put] func (h *Handler) UpdateTodo(w http.ResponseWriter, r *http.Request) { @@ -139,22 +145,25 @@ func (h *Handler) UpdateTodo(w http.ResponseWriter, r *http.Request) { return } var req dto.UpdateTodoRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { utils.RespondBadRequest(w, "invalid request body") return } - if err := h.validator.Validate(req); err != nil { + if err = h.validator.Validate(req); err != nil { utils.RespondBadRequest(w, err.Error()) return } - resp, err := h.appService.UpdateTodo(r.Context(), id, req) + resp, err := h.commandBus.Dispatch(r.Context(), command.UpdateTodoCommand{ + ID: id, + Title: req.Title, + Description: req.Description, + }) if err != nil { - switch err { - case service.ErrTodoNotFound: + if errors.Is(err, service.ErrTodoNotFound) { utils.RespondNotFound(w, "todo not found") - default: - utils.RespondInternalError(w, "failed to update todo") + return } + utils.Handle(w, r, nil, err) return } utils.RespondSuccess(w, resp) @@ -165,9 +174,9 @@ func (h *Handler) UpdateTodo(w http.ResponseWriter, r *http.Request) { // @Description Delete a todo item by ID // @Tags todos // @Param id path string true "Todo ID" -// @Success 200 {object} utils.SuccessResponse -// @Failure 400 {object} utils.ErrorResponse -// @Failure 404 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse +// @Failure 400 {object} utils.APIResponse +// @Failure 404 {object} utils.APIResponse // @Security BearerAuth // @Router /todos/{id} [delete] func (h *Handler) DeleteTodo(w http.ResponseWriter, r *http.Request) { @@ -176,16 +185,16 @@ func (h *Handler) DeleteTodo(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, "invalid todo ID") return } - if err := h.appService.DeleteTodo(r.Context(), id); err != nil { - switch err { - case service.ErrTodoNotFound: + _, err = h.commandBus.Dispatch(r.Context(), command.DeleteTodoCommand{ID: id}) + if err != nil { + if errors.Is(err, service.ErrTodoNotFound) { utils.RespondNotFound(w, "todo not found") - default: - utils.RespondInternalError(w, "failed to delete todo") + return } + utils.Handle(w, r, nil, err) return } - utils.RespondSuccess(w, nil) + utils.RespondSuccess(w, map[string]string{"message": "todo deleted"}) } // CompleteTodo godoc @@ -193,10 +202,10 @@ func (h *Handler) DeleteTodo(w http.ResponseWriter, r *http.Request) { // @Description Mark a todo item as completed // @Tags todos // @Param id path string true "Todo ID" -// @Success 200 {object} utils.SuccessResponse{data=dto.TodoResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 404 {object} utils.ErrorResponse -// @Failure 409 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.TodoResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 404 {object} utils.APIResponse +// @Failure 409 {object} utils.APIResponse // @Security BearerAuth // @Router /todos/{id}/complete [patch] func (h *Handler) CompleteTodo(w http.ResponseWriter, r *http.Request) { @@ -205,15 +214,15 @@ func (h *Handler) CompleteTodo(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, "invalid todo ID") return } - resp, err := h.appService.CompleteTodo(r.Context(), id) + resp, err := h.commandBus.Dispatch(r.Context(), command.CompleteTodoCommand{ID: id}) if err != nil { - switch err { - case service.ErrTodoNotFound: + switch { + case errors.Is(err, service.ErrTodoNotFound): utils.RespondNotFound(w, "todo not found") - case service.ErrTodoAlreadyDone: + case errors.Is(err, service.ErrTodoAlreadyDone): utils.RespondConflict(w, "todo is already completed") default: - utils.RespondInternalError(w, "failed to complete todo") + utils.MapErrorFromRequest(w, r, err) } return } @@ -228,9 +237,9 @@ func (h *Handler) CompleteTodo(w http.ResponseWriter, r *http.Request) { // @Param q query string true "Search query" // @Param page query int false "Page number" default(1) // @Param per_page query int false "Items per page" default(20) -// @Success 200 {object} utils.SuccessResponse{data=dto.TodoListResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 500 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=dto.TodoListResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 500 {object} utils.APIResponse // @Security BearerAuth // @Router /todos/search [get] func (h *Handler) SearchTodos(w http.ResponseWriter, r *http.Request) { @@ -239,18 +248,24 @@ func (h *Handler) SearchTodos(w http.ResponseWriter, r *http.Request) { utils.RespondBadRequest(w, "search query is required") return } - page := 1 - perPage := 20 - if p := r.URL.Query().Get("page"); p != "" { - fmt.Sscanf(p, "%d", &page) + cursorStr := r.URL.Query().Get("cursor") + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 { + limit = n + } } - if pp := r.URL.Query().Get("per_page"); pp != "" { - fmt.Sscanf(pp, "%d", &perPage) + + var cursor *string + if cursorStr != "" { + cursor = &cursorStr } - resp, err := h.appService.SearchTodos(r.Context(), queryStr, page, perPage) + + resp, err := h.queryBus.Ask(r.Context(), query.SearchTodosQuery{Query: queryStr, Cursor: cursor, Limit: limit}) if err != nil { - utils.RespondInternalError(w, "failed to search todos") + utils.MapErrorFromRequest(w, r, err) return } - utils.RespondSuccess(w, resp) -} \ No newline at end of file + result := resp.(query.SearchTodosResult) + utils.RespondCursorPaginated(w, result.Todos, result.NextCursor, result.PrevCursor, result.HasNext, result.HasPrev, result.Limit) +} diff --git a/internal/todo/module.go b/internal/todo/module.go index c3d0741..c17f811 100644 --- a/internal/todo/module.go +++ b/internal/todo/module.go @@ -1,10 +1,11 @@ package todo import ( + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" "github.com/IDTS-LAB/go-codebase/internal/shared/events" "github.com/IDTS-LAB/go-codebase/internal/todo/application/command" "github.com/IDTS-LAB/go-codebase/internal/todo/application/query" - appService "github.com/IDTS-LAB/go-codebase/internal/todo/application/service" + todoEvent "github.com/IDTS-LAB/go-codebase/internal/todo/domain/event" "github.com/IDTS-LAB/go-codebase/internal/todo/domain/service" "github.com/IDTS-LAB/go-codebase/internal/todo/infrastructure/eventbus" "github.com/IDTS-LAB/go-codebase/internal/todo/infrastructure/persistence" @@ -14,28 +15,13 @@ import ( var Module = fx.Module("todo", fx.Provide( - // Infrastructure - NewTodoRepository returns repository.TodoRepository + // Infrastructure persistence.NewTodoRepository, // Domain service.NewTodoDomainService, - // Application Commands - command.NewCreateTodoHandler, - command.NewUpdateTodoHandler, - command.NewDeleteTodoHandler, - command.NewCompleteTodoHandler, - - // Application Queries - query.NewGetTodoHandler, - query.NewListTodosHandler, - query.NewSearchTodosHandler, - - // Application Service - appService.NewTodoAppService, - // Events - events.NewEventBus, eventbus.NewTodoEventHandler, // Interface @@ -43,8 +29,31 @@ var Module = fx.Module("todo", ), fx.Invoke( - func(bus *events.EventBus, eh *eventbus.TodoEventHandler) { + registerHandlers, + func(bus events.EventBus, eh *eventbus.TodoEventHandler) { eh.Register(bus) }, + func() { + events.Register(todoEvent.TodoCreatedEvent, func() interface{} { return &todoEvent.TodoCreated{} }) + events.Register(todoEvent.TodoUpdatedEvent, func() interface{} { return &todoEvent.TodoUpdated{} }) + events.Register(todoEvent.TodoCompletedEvent, func() interface{} { return &todoEvent.TodoCompleted{} }) + events.Register(todoEvent.TodoDeletedEvent, func() interface{} { return &todoEvent.TodoDeleted{} }) + }, ), ) + +func registerHandlers( + commandBus cqrs.CommandBus, + queryBus cqrs.QueryBus, + domainSvc *service.TodoDomainService, + eventBus events.EventBus, +) { + commandBus.Register(command.CreateTodoCommand{}, command.NewCreateTodoHandler(domainSvc, eventBus)) + commandBus.Register(command.UpdateTodoCommand{}, command.NewUpdateTodoHandler(domainSvc, eventBus)) + commandBus.Register(command.DeleteTodoCommand{}, command.NewDeleteTodoHandler(domainSvc, eventBus)) + commandBus.Register(command.CompleteTodoCommand{}, command.NewCompleteTodoHandler(domainSvc, eventBus)) + + queryBus.Register(query.GetTodoQuery{}, query.NewGetTodoHandler(domainSvc)) + queryBus.Register(query.ListTodosQuery{}, query.NewListTodosHandler(domainSvc)) + queryBus.Register(query.SearchTodosQuery{}, query.NewSearchTodosHandler(domainSvc)) +} diff --git a/internal/todo/tests/todo_domain_test.go b/internal/todo/tests/todo_domain_test.go index aada1cc..79a46aa 100644 --- a/internal/todo/tests/todo_domain_test.go +++ b/internal/todo/tests/todo_domain_test.go @@ -165,12 +165,11 @@ func TestTodoDomainService_ListTodos(t *testing.T) { newTestTodo(uuid.New(), "Todo 1"), newTestTodo(uuid.New(), "Todo 2"), } - repo.On("GetAll", mock.Anything, 0, 10).Return(todos, 2, nil) + repo.On("GetAll", mock.Anything, (*string)(nil), 10).Return(todos, nil) - result, total, err := svc.ListTodos(context.Background(), 0, 10) + result, _, _, _, _, err := svc.ListTodos(context.Background(), nil, 10) assert.NoError(t, err) - assert.Equal(t, 2, total) assert.Len(t, result, 2) } @@ -181,11 +180,10 @@ func TestTodoDomainService_SearchTodos(t *testing.T) { todos := []*entity.Todo{ newTestTodo(uuid.New(), "Test Todo"), } - repo.On("Search", mock.Anything, "test", 0, 10).Return(todos, 1, nil) + repo.On("Search", mock.Anything, "test", (*string)(nil), 10).Return(todos, nil) - result, total, err := svc.SearchTodos(context.Background(), "test", 0, 10) + result, _, _, _, _, err := svc.SearchTodos(context.Background(), "test", nil, 10) assert.NoError(t, err) - assert.Equal(t, 1, total) assert.Len(t, result, 1) } diff --git a/internal/todo/tests/todo_handler_test.go b/internal/todo/tests/todo_handler_test.go index 5b872eb..8577412 100644 --- a/internal/todo/tests/todo_handler_test.go +++ b/internal/todo/tests/todo_handler_test.go @@ -8,11 +8,12 @@ import ( "net/http/httptest" "testing" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/shared/events" "github.com/IDTS-LAB/go-codebase/internal/shared/validator" "github.com/IDTS-LAB/go-codebase/internal/todo/application/command" "github.com/IDTS-LAB/go-codebase/internal/todo/application/dto" "github.com/IDTS-LAB/go-codebase/internal/todo/application/query" - appService "github.com/IDTS-LAB/go-codebase/internal/todo/application/service" "github.com/IDTS-LAB/go-codebase/internal/todo/domain/entity" "github.com/IDTS-LAB/go-codebase/internal/todo/domain/service" httpHandler "github.com/IDTS-LAB/go-codebase/internal/todo/interfaces/http" @@ -39,9 +40,9 @@ func (m *MockTodoRepo) GetByID(ctx context.Context, id uuid.UUID) (*entity.Todo, return args.Get(0).(*entity.Todo), args.Error(1) } -func (m *MockTodoRepo) GetAll(ctx context.Context, offset, limit int) ([]*entity.Todo, int, error) { - args := m.Called(ctx, offset, limit) - return args.Get(0).([]*entity.Todo), args.Int(1), args.Error(2) +func (m *MockTodoRepo) GetAll(ctx context.Context, cursor *string, limit int) ([]*entity.Todo, *string, *string, bool, bool, error) { + args := m.Called(ctx, cursor, limit) + return args.Get(0).([]*entity.Todo), nil, nil, false, false, args.Error(1) } func (m *MockTodoRepo) Update(ctx context.Context, todo *entity.Todo) error { @@ -54,27 +55,31 @@ func (m *MockTodoRepo) Delete(ctx context.Context, id uuid.UUID) error { return args.Error(0) } -func (m *MockTodoRepo) Search(ctx context.Context, queryStr string, offset, limit int) ([]*entity.Todo, int, error) { - args := m.Called(ctx, queryStr, offset, limit) - return args.Get(0).([]*entity.Todo), args.Int(1), args.Error(2) +func (m *MockTodoRepo) Search(ctx context.Context, queryStr string, cursor *string, limit int) ([]*entity.Todo, *string, *string, bool, bool, error) { + args := m.Called(ctx, queryStr, cursor, limit) + return args.Get(0).([]*entity.Todo), nil, nil, false, false, args.Error(1) } func setupHandler(t *testing.T) (*httpHandler.Handler, *MockTodoRepo) { t.Helper() repo := new(MockTodoRepo) domainSvc := service.NewTodoDomainService(repo) + eventBus := events.NewInMemoryEventBus() - createH := command.NewCreateTodoHandler(domainSvc) - updateH := command.NewUpdateTodoHandler(domainSvc) - deleteH := command.NewDeleteTodoHandler(domainSvc) - completeH := command.NewCompleteTodoHandler(domainSvc) - getH := query.NewGetTodoHandler(domainSvc) - listH := query.NewListTodosHandler(domainSvc) - searchH := query.NewSearchTodosHandler(domainSvc) + cmdBus := cqrs.NewInMemoryCommandBus() + qBus := cqrs.NewInMemoryQueryBus() + + cmdBus.Register(command.CreateTodoCommand{}, command.NewCreateTodoHandler(domainSvc, eventBus)) + cmdBus.Register(command.UpdateTodoCommand{}, command.NewUpdateTodoHandler(domainSvc, eventBus)) + cmdBus.Register(command.DeleteTodoCommand{}, command.NewDeleteTodoHandler(domainSvc, eventBus)) + cmdBus.Register(command.CompleteTodoCommand{}, command.NewCompleteTodoHandler(domainSvc, eventBus)) + + qBus.Register(query.GetTodoQuery{}, query.NewGetTodoHandler(domainSvc)) + qBus.Register(query.ListTodosQuery{}, query.NewListTodosHandler(domainSvc)) + qBus.Register(query.SearchTodosQuery{}, query.NewSearchTodosHandler(domainSvc)) - appSvc := appService.NewTodoAppService(createH, updateH, deleteH, completeH, getH, listH, searchH) v := validator.New() - h := httpHandler.NewHandler(appSvc, v) + h := httpHandler.NewHandler(cmdBus, qBus, v) return h, repo } @@ -102,6 +107,8 @@ func TestCreateTodo_Success(t *testing.T) { var resp map[string]interface{} json.NewDecoder(rr.Body).Decode(&resp) assert.True(t, resp["success"].(bool)) + assert.NotNil(t, resp["data"]) + assert.Nil(t, resp["meta"]) repo.AssertExpectations(t) } @@ -116,6 +123,11 @@ func TestCreateTodo_ValidationError(t *testing.T) { h.CreateTodo(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + assert.Equal(t, "VALIDATION_ERROR", resp["error"].(map[string]interface{})["code"]) } func TestGetTodo_Success(t *testing.T) { @@ -132,6 +144,11 @@ func TestGetTodo_Success(t *testing.T) { h.GetTodo(rr, req) assert.Equal(t, http.StatusOK, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) + assert.NotNil(t, resp["data"]) + assert.Nil(t, resp["meta"]) repo.AssertExpectations(t) } @@ -148,6 +165,11 @@ func TestGetTodo_NotFound(t *testing.T) { h.GetTodo(rr, req) assert.Equal(t, http.StatusNotFound, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + assert.Equal(t, "NOT_FOUND", resp["error"].(map[string]interface{})["code"]) repo.AssertExpectations(t) } @@ -166,6 +188,10 @@ func TestDeleteTodo_Success(t *testing.T) { h.DeleteTodo(rr, req) assert.Equal(t, http.StatusOK, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) + assert.Nil(t, resp["meta"]) repo.AssertExpectations(t) } @@ -183,6 +209,11 @@ func TestCompleteTodo_AlreadyDone(t *testing.T) { h.CompleteTodo(rr, req) assert.Equal(t, http.StatusConflict, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + assert.Equal(t, "CONFLICT", resp["error"].(map[string]interface{})["code"]) repo.AssertExpectations(t) } @@ -201,6 +232,11 @@ func TestCompleteTodo_Success(t *testing.T) { h.CompleteTodo(rr, req) assert.Equal(t, http.StatusOK, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) + assert.NotNil(t, resp["data"]) + assert.Nil(t, resp["meta"]) repo.AssertExpectations(t) } @@ -209,7 +245,7 @@ func TestSearchTodos_Success(t *testing.T) { id := uuid.New() todos := []*entity.Todo{newTestTodo(id, "Test")} - repo.On("Search", mock.Anything, "test", 0, 20).Return(todos, 1, nil) + repo.On("Search", mock.Anything, "test", (*string)(nil), 20).Return(todos, nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/todos/search?q=test", nil) rr := httptest.NewRecorder() @@ -217,6 +253,11 @@ func TestSearchTodos_Success(t *testing.T) { h.SearchTodos(rr, req) assert.Equal(t, http.StatusOK, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.True(t, resp["success"].(bool)) + assert.NotNil(t, resp["data"]) + assert.NotNil(t, resp["meta"]) repo.AssertExpectations(t) } @@ -229,4 +270,9 @@ func TestSearchTodos_MissingQuery(t *testing.T) { h.SearchTodos(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code) + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + assert.False(t, resp["success"].(bool)) + assert.Nil(t, resp["data"]) + assert.Equal(t, "VALIDATION_ERROR", resp["error"].(map[string]interface{})["code"]) } diff --git a/internal/user/application/command/delete_user.go b/internal/user/application/command/delete_user.go new file mode 100644 index 0000000..18333ee --- /dev/null +++ b/internal/user/application/command/delete_user.go @@ -0,0 +1,30 @@ +package command + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/user/domain/repository" + "github.com/google/uuid" +) + +type DeleteUserCommand struct { + ID uuid.UUID +} + +type DeleteUserHandler struct { + repo repository.UserRepository +} + +func NewDeleteUserHandler(repo repository.UserRepository) *DeleteUserHandler { + return &DeleteUserHandler{repo: repo} +} + +func (h *DeleteUserHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(DeleteUserCommand) + user, err := h.repo.GetByID(ctx, c.ID) + if err != nil { + return nil, err + } + user.SoftDelete() + return nil, h.repo.Update(ctx, user) +} diff --git a/internal/user/application/command/update_user.go b/internal/user/application/command/update_user.go new file mode 100644 index 0000000..a915384 --- /dev/null +++ b/internal/user/application/command/update_user.go @@ -0,0 +1,43 @@ +package command + +import ( + "context" + + "github.com/IDTS-LAB/go-codebase/internal/user/domain/repository" + "github.com/google/uuid" +) + +type UpdateUserCommand struct { + ID uuid.UUID + Name string + Email string + IsActive bool +} + +type UpdateUserHandler struct { + repo repository.UserRepository +} + +func NewUpdateUserHandler(repo repository.UserRepository) *UpdateUserHandler { + return &UpdateUserHandler{repo: repo} +} + +func (h *UpdateUserHandler) Handle(ctx context.Context, cmd any) (any, error) { + c := cmd.(UpdateUserCommand) + user, err := h.repo.GetByID(ctx, c.ID) + if err != nil { + return nil, err + } + if c.Name != "" { + user.Name = c.Name + } + if c.Email != "" { + user.Email = c.Email + } + user.IsActive = c.IsActive + user.Touch() + if err := h.repo.Update(ctx, user); err != nil { + return nil, err + } + return user, nil +} diff --git a/internal/user/application/query/get_user.go b/internal/user/application/query/get_user.go new file mode 100644 index 0000000..63e8a72 --- /dev/null +++ b/internal/user/application/query/get_user.go @@ -0,0 +1,27 @@ +package query + +import ( + "context" + + roleProvider "github.com/IDTS-LAB/go-codebase/internal/authorization/public" + "github.com/IDTS-LAB/go-codebase/internal/user/domain/repository" + "github.com/google/uuid" +) + +type GetUserQuery struct { + ID uuid.UUID +} + +type GetUserHandler struct { + repo repository.UserRepository + roleProvider roleProvider.AuthorizationProvider +} + +func NewGetUserHandler(repo repository.UserRepository, roleProvider roleProvider.AuthorizationProvider) *GetUserHandler { + return &GetUserHandler{repo: repo, roleProvider: roleProvider} +} + +func (h *GetUserHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(GetUserQuery) + return h.repo.GetByID(ctx, q.ID) +} diff --git a/internal/user/application/query/list_users.go b/internal/user/application/query/list_users.go new file mode 100644 index 0000000..b207a40 --- /dev/null +++ b/internal/user/application/query/list_users.go @@ -0,0 +1,46 @@ +package query + +import ( + "context" + + authEntity "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/user/domain/repository" +) + +type ListUsersQuery struct { + Cursor *string + Limit int +} + +type ListUsersResult struct { + Users []*authEntity.User + NextCursor *string + PrevCursor *string + HasNext bool + HasPrev bool + Limit int +} + +type ListUsersHandler struct { + repo repository.UserRepository +} + +func NewListUsersHandler(repo repository.UserRepository) *ListUsersHandler { + return &ListUsersHandler{repo: repo} +} + +func (h *ListUsersHandler) Handle(ctx context.Context, query any) (any, error) { + q := query.(ListUsersQuery) + users, nextCursor, prevCursor, hasNext, hasPrev, err := h.repo.List(ctx, q.Cursor, q.Limit) + if err != nil { + return nil, err + } + return ListUsersResult{ + Users: users, + NextCursor: nextCursor, + PrevCursor: prevCursor, + HasNext: hasNext, + HasPrev: hasPrev, + Limit: q.Limit, + }, nil +} diff --git a/internal/user/application/service/user_service.go b/internal/user/application/service/user_service.go deleted file mode 100644 index 6b311e0..0000000 --- a/internal/user/application/service/user_service.go +++ /dev/null @@ -1,59 +0,0 @@ -package service - -import ( - "context" - "fmt" - - "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" - "github.com/IDTS-LAB/go-codebase/internal/user/domain/repository" - "github.com/google/uuid" -) - -type UserService struct { - repo repository.UserRepository -} - -func NewUserService(repo repository.UserRepository) *UserService { - return &UserService{repo: repo} -} - -func (s *UserService) List(ctx context.Context, offset, limit int) ([]*entity.User, int, error) { - return s.repo.List(ctx, offset, limit) -} - -func (s *UserService) GetByID(ctx context.Context, id uuid.UUID) (*entity.User, error) { - user, err := s.repo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("user not found: %w", err) - } - return user, nil -} - -func (s *UserService) Update(ctx context.Context, id uuid.UUID, name string, email string, isActive bool) (*entity.User, error) { - user, err := s.repo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("user not found: %w", err) - } - if name != "" { - user.Name = name - } - if email != "" { - user.Email = email - } - user.IsActive = isActive - user.Touch() - - if err := s.repo.Update(ctx, user); err != nil { - return nil, fmt.Errorf("update user: %w", err) - } - return user, nil -} - -func (s *UserService) Delete(ctx context.Context, id uuid.UUID) error { - user, err := s.repo.GetByID(ctx, id) - if err != nil { - return fmt.Errorf("user not found: %w", err) - } - user.SoftDelete() - return s.repo.Update(ctx, user) -} diff --git a/internal/user/application/user_provider.go b/internal/user/application/user_provider.go new file mode 100644 index 0000000..f31b5f2 --- /dev/null +++ b/internal/user/application/user_provider.go @@ -0,0 +1,46 @@ +package application + +import ( + "context" + + authEntity "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/authorization/public" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/user/application/query" + userPublic "github.com/IDTS-LAB/go-codebase/internal/user/public" + "github.com/google/uuid" +) + +type userProfileProvider struct { + queryBus cqrs.QueryBus + authProvider public.AuthorizationProvider +} + +func NewUserProfileProvider(queryBus cqrs.QueryBus, authProvider public.AuthorizationProvider) userPublic.UserProfileProvider { + return &userProfileProvider{queryBus: queryBus, authProvider: authProvider} +} + +func (p *userProfileProvider) GetProfile(ctx context.Context, userID uuid.UUID) (*userPublic.UserProfile, error) { + resp, err := p.queryBus.Ask(ctx, query.GetUserQuery{ID: userID}) + if err != nil { + return nil, err + } + + user := resp.(*authEntity.User) + + profile := &userPublic.UserProfile{ + ID: user.ID.String(), + Email: user.Email, + Name: user.Name, + IsActive: user.IsActive, + CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + + roles, err := p.authProvider.GetUserRoles(ctx, userID) + if err == nil { + profile.Roles = roles + } + + return profile, nil +} diff --git a/internal/user/domain/repository/user_repository.go b/internal/user/domain/repository/user_repository.go index 46a27c6..a1bb33c 100644 --- a/internal/user/domain/repository/user_repository.go +++ b/internal/user/domain/repository/user_repository.go @@ -8,7 +8,7 @@ import ( ) type UserRepository interface { - List(ctx context.Context, offset, limit int) ([]*entity.User, int, error) + List(ctx context.Context, cursor *string, limit int) ([]*entity.User, *string, *string, bool, bool, error) GetByID(ctx context.Context, id uuid.UUID) (*entity.User, error) Update(ctx context.Context, user *entity.User) error Delete(ctx context.Context, id uuid.UUID) error diff --git a/internal/user/infrastructure/persistence/queries/queries.sql b/internal/user/infrastructure/persistence/queries/queries.sql new file mode 100644 index 0000000..5463d4e --- /dev/null +++ b/internal/user/infrastructure/persistence/queries/queries.sql @@ -0,0 +1,9 @@ +-- name: GetUserByID :one +SELECT id, email, name, is_active, created_at, updated_at, deleted_at +FROM users WHERE id = $1 AND deleted_at IS NULL; + +-- name: UpdateUser :execrows +UPDATE users SET email = $2, name = $3, is_active = $4, updated_at = $5 WHERE id = $1 AND deleted_at IS NULL; + +-- name: DeleteUser :execrows +UPDATE users SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL; diff --git a/internal/user/infrastructure/persistence/sqlc/db.go b/internal/user/infrastructure/persistence/sqlc/db.go new file mode 100644 index 0000000..5922bc9 --- /dev/null +++ b/internal/user/infrastructure/persistence/sqlc/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/user/infrastructure/persistence/sqlc/models.go b/internal/user/infrastructure/persistence/sqlc/models.go new file mode 100644 index 0000000..4a7067e --- /dev/null +++ b/internal/user/infrastructure/persistence/sqlc/models.go @@ -0,0 +1,225 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package sqlc + +import ( + "database/sql" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type AuditLog struct { + ID uuid.UUID `json:"id"` + RequestID string `json:"request_id"` + UserID uuid.NullUUID `json:"user_id"` + UserEmail sql.NullString `json:"user_email"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int32 `json:"status_code"` + DurationMs int64 `json:"duration_ms"` + Ip string `json:"ip"` + UserAgent string `json:"user_agent"` + RequestBody sql.NullString `json:"request_body"` + ResponseSize int32 `json:"response_size"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type CasbinRule struct { + ID int32 `json:"id"` + Ptype string `json:"ptype"` + V0 sql.NullString `json:"v0"` + V1 sql.NullString `json:"v1"` + V2 sql.NullString `json:"v2"` + V3 sql.NullString `json:"v3"` + V4 sql.NullString `json:"v4"` + V5 sql.NullString `json:"v5"` +} + +type ErrorLog struct { + ID uuid.UUID `json:"id"` + RequestID string `json:"request_id"` + UserID uuid.NullUUID `json:"user_id"` + UserEmail sql.NullString `json:"user_email"` + Level string `json:"level"` + Message string `json:"message"` + Error string `json:"error"` + StackTrace string `json:"stack_trace"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int32 `json:"status_code"` + Ip string `json:"ip"` + UserAgent string `json:"user_agent"` + RequestBody sql.NullString `json:"request_body"` + Metadata []byte `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type Permission struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Resource string `json:"resource"` + Action string `json:"action"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + TenantID string `json:"tenant_id"` +} + +type RefreshToken struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + RevokedAt sql.NullTime `json:"revoked_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +type Role struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + TenantID string `json:"tenant_id"` +} + +type RolePermission struct { + RoleID uuid.UUID `json:"role_id"` + PermissionID uuid.UUID `json:"permission_id"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type Tenant struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Domain sql.NullString `json:"domain"` + Settings json.RawMessage `json:"settings"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Todo struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Completed bool `json:"completed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + TenantID string `json:"tenant_id"` +} + +type User struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + FailedLoginAttempts int32 `json:"failed_login_attempts"` + EmailVerified sql.NullBool `json:"email_verified"` + EmailVerifyToken sql.NullString `json:"email_verify_token"` + EmailVerifyExpires sql.NullTime `json:"email_verify_expires"` + PasswordResetToken sql.NullString `json:"password_reset_token"` + PasswordResetExpires sql.NullTime `json:"password_reset_expires"` + TenantID string `json:"tenant_id"` + EmailVerifiedAt sql.NullTime `json:"email_verified_at"` +} + +type UserAddress struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Label string `json:"label"` + IsDefault bool `json:"is_default"` + Street string `json:"street"` + City string `json:"city"` + State string `json:"state"` + PostalCode string `json:"postal_code"` + Country string `json:"country"` +} + +type UserCredential struct { + UserID uuid.UUID `json:"user_id"` + PasswordHash string `json:"password_hash"` + LastLoginAt sql.NullTime `json:"last_login_at"` +} + +type UserPreference struct { + UserID uuid.UUID `json:"user_id"` + Preferences json.RawMessage `json:"preferences"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserProfile struct { + UserID uuid.UUID `json:"user_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Phone sql.NullString `json:"phone"` + AvatarUrl sql.NullString `json:"avatar_url"` + Timezone string `json:"timezone"` + Locale string `json:"locale"` + Bio sql.NullString `json:"bio"` +} + +type UserRole struct { + UserID uuid.UUID `json:"user_id"` + RoleID uuid.UUID `json:"role_id"` + CreatedAt time.Time `json:"created_at"` + TenantID string `json:"tenant_id"` +} + +type UserSecurity struct { + UserID uuid.UUID `json:"user_id"` + LoginAttempts int32 `json:"login_attempts"` + LockedUntil sql.NullTime `json:"locked_until"` + MfaEnabled bool `json:"mfa_enabled"` + MfaSecret sql.NullString `json:"mfa_secret"` +} + +type UserSession struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + RefreshTokenHash sql.NullString `json:"refresh_token_hash"` + DeviceInfo string `json:"device_info"` + IpAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + ExpiresAt sql.NullTime `json:"expires_at"` + LastUsedAt time.Time `json:"last_used_at"` + RevokedAt sql.NullTime `json:"revoked_at"` + CreatedAt time.Time `json:"created_at"` +} + +type UserSocialLink struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Provider string `json:"provider"` + ProviderID string `json:"provider_id"` + ProviderEmail sql.NullString `json:"provider_email"` + AvatarUrl sql.NullString `json:"avatar_url"` + CreatedAt time.Time `json:"created_at"` +} + +type UserToken struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + TokenType string `json:"token_type"` + TokenHash sql.NullString `json:"token_hash"` + ExpiresAt sql.NullTime `json:"expires_at"` + ConsumedAt sql.NullTime `json:"consumed_at"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/user/infrastructure/persistence/sqlc/queries.sql.go b/internal/user/infrastructure/persistence/sqlc/queries.sql.go new file mode 100644 index 0000000..2453399 --- /dev/null +++ b/internal/user/infrastructure/persistence/sqlc/queries.sql.go @@ -0,0 +1,82 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: queries.sql + +package sqlc + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" +) + +const deleteUser = `-- name: DeleteUser :execrows +UPDATE users SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL +` + +func (q *Queries) DeleteUser(ctx context.Context, id uuid.UUID) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteUser, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, email, name, is_active, created_at, updated_at, deleted_at +FROM users WHERE id = $1 AND deleted_at IS NULL +` + +type GetUserByIDRow struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow, error) { + row := q.db.QueryRowContext(ctx, getUserByID, id) + var i GetUserByIDRow + err := row.Scan( + &i.ID, + &i.Email, + &i.Name, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const updateUser = `-- name: UpdateUser :execrows +UPDATE users SET email = $2, name = $3, is_active = $4, updated_at = $5 WHERE id = $1 AND deleted_at IS NULL +` + +type UpdateUserParams struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + IsActive bool `json:"is_active"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateUser, + arg.ID, + arg.Email, + arg.Name, + arg.IsActive, + arg.UpdatedAt, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/user/infrastructure/persistence/user_repository.go b/internal/user/infrastructure/persistence/user_repository.go index 574a1f8..59f0bb6 100644 --- a/internal/user/infrastructure/persistence/user_repository.go +++ b/internal/user/infrastructure/persistence/user_repository.go @@ -4,71 +4,133 @@ import ( "context" "database/sql" "fmt" + "time" "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/core/domain" + "github.com/IDTS-LAB/go-codebase/internal/shared/cursor" + "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" + "github.com/IDTS-LAB/go-codebase/internal/shared/tenantfilter" "github.com/IDTS-LAB/go-codebase/internal/user/domain/repository" + "github.com/IDTS-LAB/go-codebase/internal/user/infrastructure/persistence/sqlc" "github.com/google/uuid" ) type userRepository struct { - db *sql.DB + db *sql.DB + tenantConfig *tenantfilter.Config } -func NewUserRepository(db *sql.DB) repository.UserRepository { - return &userRepository{db: db} +func NewUserRepository(db *sql.DB, tenantConfig *tenantfilter.Config) repository.UserRepository { + return &userRepository{db: db, tenantConfig: tenantConfig} } -func (r *userRepository) List(ctx context.Context, offset, limit int) ([]*entity.User, int, error) { - var total int - err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE deleted_at IS NULL`).Scan(&total) - if err != nil { - return nil, 0, fmt.Errorf("count users: %w", err) +func (r *userRepository) List(ctx context.Context, cursorArg *string, limit int) ([]*entity.User, *string, *string, bool, bool, error) { + args := []interface{}{} + whereClause := "WHERE u.deleted_at IS NULL" + + if r.tenantConfig != nil && r.tenantConfig.Enabled { + tenantID := middleware.GetTenantID(ctx) + if tenantID != "" { + whereClause += fmt.Sprintf(" AND u.tenant_id = $%d", len(args)+1) + args = append(args, tenantID) + } + } + + nextPos := len(args) + 1 + if cursorArg != nil { + c, err := cursor.Decode(*cursorArg) + if err != nil { + return nil, nil, nil, false, false, fmt.Errorf("invalid cursor: %w", err) + } + whereClause += fmt.Sprintf(" AND (u.created_at, u.id) < ($%d, $%d)", nextPos, nextPos+1) + args = append(args, c.Timestamp, c.ID) + nextPos += 2 } - rows, err := r.db.QueryContext(ctx, - `SELECT id, email, name, is_active, created_at, updated_at FROM users WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT $1 OFFSET $2`, - limit, offset, - ) + dataQuery := fmt.Sprintf("SELECT u.id, u.email, u.name, u.is_active, u.created_at, u.updated_at, u.deleted_at FROM users u %s ORDER BY u.created_at DESC, u.id DESC LIMIT $%d", whereClause, nextPos) + args = append(args, limit+1) + + rows, err := r.db.QueryContext(ctx, dataQuery, args...) if err != nil { - return nil, 0, fmt.Errorf("list users: %w", err) + return nil, nil, nil, false, false, fmt.Errorf("list users: %w", err) } defer rows.Close() var users []*entity.User for rows.Next() { - u := &entity.User{} - if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.IsActive, &u.CreatedAt, &u.UpdatedAt); err != nil { - return nil, 0, fmt.Errorf("scan user: %w", err) + var u entity.User + var deletedAt sql.NullTime + if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.IsActive, &u.CreatedAt, &u.UpdatedAt, &deletedAt); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("scan user: %w", err) + } + if deletedAt.Valid { + u.DeletedAt = &deletedAt.Time } - users = append(users, u) + users = append(users, &u) } - return users, total, nil + if err := rows.Err(); err != nil { + return nil, nil, nil, false, false, fmt.Errorf("rows iteration: %w", err) + } + + hasNext := len(users) > limit + if hasNext { + users = users[:limit] + } + + var nextCursor *string + var prevCursor *string + if len(users) > 0 { + last := users[len(users)-1] + nc := cursor.Encode(last.CreatedAt, last.ID) + nextCursor = &nc + + first := users[0] + pc := cursor.Encode(first.CreatedAt, first.ID) + prevCursor = &pc + } + + hasPrev := cursorArg != nil + if hasPrev && len(users) == 0 { + hasPrev = false + } + + return users, nextCursor, prevCursor, hasNext, hasPrev, nil } func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*entity.User, error) { - u := &entity.User{} - err := r.db.QueryRowContext(ctx, - `SELECT id, email, name, is_active, created_at, updated_at FROM users WHERE id = $1 AND deleted_at IS NULL`, - id, - ).Scan(&u.ID, &u.Email, &u.Name, &u.IsActive, &u.CreatedAt, &u.UpdatedAt) + q := sqlc.New(r.db) + row, err := q.GetUserByID(ctx, id) if err == sql.ErrNoRows { return nil, fmt.Errorf("user not found") } if err != nil { return nil, fmt.Errorf("get user: %w", err) } + u := &entity.User{ + Entity: domain.Entity{ID: row.ID, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, + Email: row.Email, + Name: row.Name, + IsActive: row.IsActive, + } + if row.DeletedAt.Valid { + u.DeletedAt = &row.DeletedAt.Time + } return u, nil } func (r *userRepository) Update(ctx context.Context, user *entity.User) error { - result, err := r.db.ExecContext(ctx, - `UPDATE users SET email = $2, name = $3, is_active = $4, updated_at = $5, deleted_at = $6 WHERE id = $1`, - user.ID, user.Email, user.Name, user.IsActive, user.UpdatedAt, user.DeletedAt, - ) + q := sqlc.New(r.db) + rows, err := q.UpdateUser(ctx, sqlc.UpdateUserParams{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + IsActive: user.IsActive, + UpdatedAt: time.Now().UTC(), + }) if err != nil { return fmt.Errorf("update user: %w", err) } - rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("user not found") } @@ -76,14 +138,11 @@ func (r *userRepository) Update(ctx context.Context, user *entity.User) error { } func (r *userRepository) Delete(ctx context.Context, id uuid.UUID) error { - result, err := r.db.ExecContext(ctx, - `UPDATE users SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL`, - id, - ) + q := sqlc.New(r.db) + rows, err := q.DeleteUser(ctx, id) if err != nil { return fmt.Errorf("delete user: %w", err) } - rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("user not found") } diff --git a/internal/user/interfaces/http/handler.go b/internal/user/interfaces/http/handler.go index e6e9499..881fffd 100644 --- a/internal/user/interfaces/http/handler.go +++ b/internal/user/interfaces/http/handler.go @@ -5,28 +5,35 @@ import ( "net/http" "strconv" + authEntity "github.com/IDTS-LAB/go-codebase/internal/authentication/domain/entity" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" "github.com/IDTS-LAB/go-codebase/internal/shared/middleware" "github.com/IDTS-LAB/go-codebase/internal/shared/utils" - "github.com/IDTS-LAB/go-codebase/internal/user/application/service" - "github.com/google/uuid" + "github.com/IDTS-LAB/go-codebase/internal/user/application/command" + "github.com/IDTS-LAB/go-codebase/internal/user/application/query" + "github.com/IDTS-LAB/go-codebase/internal/user/public" "github.com/go-chi/chi/v5" + "github.com/google/uuid" ) type Handler struct { - svc *service.UserService + commandBus cqrs.CommandBus + queryBus cqrs.QueryBus + profileProv public.UserProfileProvider } -func NewHandler(svc *service.UserService) *Handler { - return &Handler{svc: svc} +func NewHandler(commandBus cqrs.CommandBus, queryBus cqrs.QueryBus) *Handler { + return &Handler{commandBus: commandBus, queryBus: queryBus} } type UserResponse struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - IsActive bool `json:"is_active"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Roles []string `json:"roles"` + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type UpdateUserRequest struct { @@ -40,6 +47,17 @@ type ListResponse struct { Total int `json:"total"` } +func userToResponse(user *authEntity.User) UserResponse { + return UserResponse{ + ID: user.ID.String(), + Email: user.Email, + Name: user.Name, + IsActive: user.IsActive, + CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } +} + // List godoc // @Summary List users // @Description Get a paginated list of users @@ -47,39 +65,37 @@ type ListResponse struct { // @Produce json // @Param limit query int false "Page size" default(20) // @Param offset query int false "Offset" default(0) -// @Success 200 {object} utils.SuccessResponse{data=ListResponse} -// @Failure 500 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=ListResponse} +// @Failure 500 {object} utils.APIResponse // @Security BearerAuth // @Router /users [get] func (h *Handler) List(w http.ResponseWriter, r *http.Request) { - limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) - if limit <= 0 || limit > 100 { - limit = 20 + cursorStr := r.URL.Query().Get("cursor") + limit := 20 + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 { + limit = n + } } - if offset < 0 { - offset = 0 + + var cursor *string + if cursorStr != "" { + cursor = &cursorStr } - users, total, err := h.svc.List(r.Context(), offset, limit) + resp, err := h.queryBus.Ask(r.Context(), query.ListUsersQuery{Cursor: cursor, Limit: limit}) if err != nil { - utils.RespondInternalError(w, "failed to list users") + utils.MapErrorFromRequest(w, r, err) return } - resp := make([]UserResponse, len(users)) - for i, u := range users { - resp[i] = UserResponse{ - ID: u.ID.String(), - Email: u.Email, - Name: u.Name, - IsActive: u.IsActive, - CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"), - UpdatedAt: u.UpdatedAt.Format("2006-01-02T15:04:05Z"), - } + result := resp.(query.ListUsersResult) + usersResp := make([]UserResponse, len(result.Users)) + for i, u := range result.Users { + usersResp[i] = userToResponse(u) } - utils.RespondSuccess(w, ListResponse{Users: resp, Total: total}) + utils.RespondCursorPaginated(w, usersResp, result.NextCursor, result.PrevCursor, result.HasNext, result.HasPrev, result.Limit) } // Get godoc @@ -88,9 +104,9 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { // @Tags users // @Produce json // @Param id path string true "User ID" -// @Success 200 {object} utils.SuccessResponse{data=UserResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 404 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=UserResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 404 {object} utils.APIResponse // @Security BearerAuth // @Router /users/{id} [get] func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { @@ -100,20 +116,13 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { return } - user, err := h.svc.GetByID(r.Context(), id) + resp, err := h.queryBus.Ask(r.Context(), query.GetUserQuery{ID: id}) if err != nil { - utils.RespondNotFound(w, "user not found") + utils.Handle(w, r, nil, err) return } - utils.RespondSuccess(w, UserResponse{ - ID: user.ID.String(), - Email: user.Email, - Name: user.Name, - IsActive: user.IsActive, - CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"), - UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z"), - }) + utils.RespondSuccess(w, userToResponse(resp.(*authEntity.User))) } // Me godoc @@ -121,14 +130,14 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { // @Description Get the authenticated user's profile // @Tags users // @Produce json -// @Success 200 {object} utils.SuccessResponse{data=UserResponse} -// @Failure 401 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=UserResponse} +// @Failure 401 {object} utils.APIResponse // @Security BearerAuth // @Router /users/me [get] func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) if userID == "" { - utils.RespondError(w, http.StatusUnauthorized, "UNAUTHORIZED", "user not authenticated") + utils.RespondUnauthorized(w, "user not authenticated") return } @@ -138,20 +147,13 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { return } - user, err := h.svc.GetByID(r.Context(), id) + resp, err := h.profileProv.GetProfile(r.Context(), id) if err != nil { - utils.RespondNotFound(w, "user not found") + utils.Handle(w, r, nil, err) return } - utils.RespondSuccess(w, UserResponse{ - ID: user.ID.String(), - Email: user.Email, - Name: user.Name, - IsActive: user.IsActive, - CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"), - UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z"), - }) + utils.RespondSuccess(w, resp) } // Update godoc @@ -162,9 +164,9 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { // @Produce json // @Param id path string true "User ID" // @Param request body UpdateUserRequest true "Update details" -// @Success 200 {object} utils.SuccessResponse{data=UserResponse} -// @Failure 400 {object} utils.ErrorResponse -// @Failure 404 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse{data=UserResponse} +// @Failure 400 {object} utils.APIResponse +// @Failure 404 {object} utils.APIResponse // @Security BearerAuth // @Router /users/{id} [put] func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { @@ -175,7 +177,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { } var req UpdateUserRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { utils.RespondBadRequest(w, "invalid request body") return } @@ -185,20 +187,18 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { isActive = *req.IsActive } - user, err := h.svc.Update(r.Context(), id, req.Name, req.Email, isActive) + resp, err := h.commandBus.Dispatch(r.Context(), command.UpdateUserCommand{ + ID: id, + Name: req.Name, + Email: req.Email, + IsActive: isActive, + }) if err != nil { - utils.RespondNotFound(w, "user not found") + utils.Handle(w, r, nil, err) return } - utils.RespondSuccess(w, UserResponse{ - ID: user.ID.String(), - Email: user.Email, - Name: user.Name, - IsActive: user.IsActive, - CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"), - UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z"), - }) + utils.RespondSuccess(w, userToResponse(resp.(*authEntity.User))) } // Delete godoc @@ -207,9 +207,9 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { // @Tags users // @Produce json // @Param id path string true "User ID" -// @Success 200 {object} utils.SuccessResponse -// @Failure 400 {object} utils.ErrorResponse -// @Failure 404 {object} utils.ErrorResponse +// @Success 200 {object} utils.APIResponse +// @Failure 400 {object} utils.APIResponse +// @Failure 404 {object} utils.APIResponse // @Security BearerAuth // @Router /users/{id} [delete] func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { @@ -219,10 +219,6 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { return } - if err := h.svc.Delete(r.Context(), id); err != nil { - utils.RespondNotFound(w, "user not found") - return - } - - utils.RespondSuccess(w, map[string]string{"message": "user deleted"}) + _, err = h.commandBus.Dispatch(r.Context(), command.DeleteUserCommand{ID: id}) + utils.Handle(w, r, map[string]string{"message": "user deleted"}, err) } diff --git a/internal/user/module.go b/internal/user/module.go index 3b16f3b..d6de152 100644 --- a/internal/user/module.go +++ b/internal/user/module.go @@ -1,16 +1,37 @@ package user import ( - httpHandler "github.com/IDTS-LAB/go-codebase/internal/user/interfaces/http" - "github.com/IDTS-LAB/go-codebase/internal/user/application/service" + roleProvider "github.com/IDTS-LAB/go-codebase/internal/authorization/public" + "github.com/IDTS-LAB/go-codebase/internal/shared/cqrs" + "github.com/IDTS-LAB/go-codebase/internal/user/application" + "github.com/IDTS-LAB/go-codebase/internal/user/application/command" + "github.com/IDTS-LAB/go-codebase/internal/user/application/query" + "github.com/IDTS-LAB/go-codebase/internal/user/domain/repository" "github.com/IDTS-LAB/go-codebase/internal/user/infrastructure/persistence" + httpHandler "github.com/IDTS-LAB/go-codebase/internal/user/interfaces/http" + "github.com/IDTS-LAB/go-codebase/internal/user/public" "go.uber.org/fx" ) var Module = fx.Module("user", fx.Provide( persistence.NewUserRepository, - service.NewUserService, httpHandler.NewHandler, + fx.Annotate(application.NewUserProfileProvider, fx.As(new(public.UserProfileProvider))), ), + + fx.Invoke(registerHandlers), ) + +func registerHandlers( + commandBus cqrs.CommandBus, + queryBus cqrs.QueryBus, + repo repository.UserRepository, + roleProvider roleProvider.AuthorizationProvider, +) { + commandBus.Register(command.UpdateUserCommand{}, command.NewUpdateUserHandler(repo)) + commandBus.Register(command.DeleteUserCommand{}, command.NewDeleteUserHandler(repo)) + + queryBus.Register(query.ListUsersQuery{}, query.NewListUsersHandler(repo)) + queryBus.Register(query.GetUserQuery{}, query.NewGetUserHandler(repo, roleProvider)) +} diff --git a/internal/user/public/provider.go b/internal/user/public/provider.go new file mode 100644 index 0000000..dfc422f --- /dev/null +++ b/internal/user/public/provider.go @@ -0,0 +1,21 @@ +package public + +import ( + "context" + + "github.com/google/uuid" +) + +type UserProfile struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Roles []string `json:"roles"` + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type UserProfileProvider interface { + GetProfile(ctx context.Context, userID uuid.UUID) (*UserProfile, error) +} diff --git a/k8s/base/configmap.yaml b/k8s/base/configmap.yaml new file mode 100644 index 0000000..f9a930f --- /dev/null +++ b/k8s/base/configmap.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: go-codebase-config + namespace: go-codebase +data: + APP_ENV: "production" + SERVER_PORT: "8080" + DB_PORT: "5432" + DB_SSLMODE: "disable" + DB_MAX_OPEN_CONNS: "25" + DB_MAX_IDLE_CONNS: "5" + DB_CONN_MAX_LIFETIME: "300" + REDIS_DB: "0" + REDIS_POOL_SIZE: "10" + MAX_LOGIN_ATTEMPTS: "5" + LOCKOUT_DURATION: "900" + TOKEN_DENYLIST: "true" + RATE_LIMIT_REQUESTS: "100" + RATE_LIMIT_WINDOW: "60" + IDEMPOTENCY_ENABLED: "false" + IDEMPOTENCY_TTL: "86400" + LOG_LEVEL: "info" + LOG_FORMAT: "json" + OTEL_SERVICE_NAME: "go-codebase" + OTEL_SAMPLE_RATE: "1.0" diff --git a/k8s/base/deployment.yaml b/k8s/base/deployment.yaml new file mode 100644 index 0000000..7ead928 --- /dev/null +++ b/k8s/base/deployment.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: go-codebase + namespace: go-codebase + labels: + app: go-codebase +spec: + replicas: 2 + selector: + matchLabels: + app: go-codebase + template: + metadata: + labels: + app: go-codebase + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + containers: + - name: api + image: ghcr.io/idts-lab/go-codebase:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: go-codebase-config + - secretRef: + name: go-codebase-secrets + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 1000m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + capabilities: + drop: + - ALL diff --git a/k8s/base/ingress.yaml b/k8s/base/ingress.yaml new file mode 100644 index 0000000..6199a0e --- /dev/null +++ b/k8s/base/ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: go-codebase + namespace: go-codebase + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + cert-manager.io/cluster-issuer: "letsencrypt-prod" +spec: + ingressClassName: nginx + tls: + - hosts: + - go-codebase.example.com + secretName: go-codebase-tls + rules: + - host: go-codebase.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: go-codebase + port: + number: 80 diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml new file mode 100644 index 0000000..47dd1a7 --- /dev/null +++ b/k8s/base/kustomization.yaml @@ -0,0 +1,16 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: go-codebase + +resources: + - namespace.yaml + - configmap.yaml + - secret.yaml + - deployment.yaml + - service.yaml + - ingress.yaml + +images: + - name: ghcr.io/idts-lab/go-codebase + newTag: latest diff --git a/k8s/base/namespace.yaml b/k8s/base/namespace.yaml new file mode 100644 index 0000000..0d8d8bf --- /dev/null +++ b/k8s/base/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: go-codebase diff --git a/k8s/base/secret.yaml b/k8s/base/secret.yaml new file mode 100644 index 0000000..dcd3d93 --- /dev/null +++ b/k8s/base/secret.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Secret +metadata: + name: go-codebase-secrets + namespace: go-codebase +type: Opaque +stringData: + DB_HOST: "" + DB_USER: "" + DB_PASSWORD: "" + DB_NAME: "" + REDIS_ADDR: "" + REDIS_PASSWORD: "" + NATS_URL: "" + JWT_SECRET: "" + OTEL_EXPORTER_ENDPOINT: "" + EMAIL_PROVIDER: "console" + EMAIL_FROM: "" + EMAIL_FROM_NAME: "" + FRONTEND_URL: "" + SMTP_HOST: "" + SMTP_PORT: "587" + SMTP_USERNAME: "" + SMTP_PASSWORD: "" + SMTP_USE_TLS: "true" + SENDGRID_API_KEY: "" diff --git a/k8s/base/service.yaml b/k8s/base/service.yaml new file mode 100644 index 0000000..cd6c778 --- /dev/null +++ b/k8s/base/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: go-codebase + namespace: go-codebase + labels: + app: go-codebase +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + app: go-codebase diff --git a/k8s/overlays/production/kustomization.yaml b/k8s/overlays/production/kustomization.yaml new file mode 100644 index 0000000..20e5b03 --- /dev/null +++ b/k8s/overlays/production/kustomization.yaml @@ -0,0 +1,32 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: go-codebase-production + +resources: + - ../../base + +namePrefix: production- + +replicas: + - name: go-codebase + count: 3 + +images: + - name: ghcr.io/idts-lab/go-codebase + newTag: v1.0.0 + +patches: + - target: + kind: Ingress + name: go-codebase + patch: |- + - op: replace + path: /spec/rules/0/host + value: go-codebase.example.com + - op: replace + path: /spec/tls/0/hosts/0 + value: go-codebase.example.com + - op: replace + path: /spec/tls/0/secretName + value: production-go-codebase-tls diff --git a/k8s/overlays/staging/kustomization.yaml b/k8s/overlays/staging/kustomization.yaml new file mode 100644 index 0000000..bbe7b92 --- /dev/null +++ b/k8s/overlays/staging/kustomization.yaml @@ -0,0 +1,32 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: go-codebase-staging + +resources: + - ../../base + +namePrefix: staging- + +replicas: + - name: go-codebase + count: 1 + +images: + - name: ghcr.io/idts-lab/go-codebase + newTag: main + +patches: + - target: + kind: Ingress + name: go-codebase + patch: |- + - op: replace + path: /spec/rules/0/host + value: staging.go-codebase.example.com + - op: replace + path: /spec/tls/0/hosts/0 + value: staging.go-codebase.example.com + - op: replace + path: /spec/tls/0/secretName + value: staging-go-codebase-tls diff --git a/migrations/001_create_todos.sql b/migrations/001_create_todos.sql index 36d8bc1..b671bd8 100644 --- a/migrations/001_create_todos.sql +++ b/migrations/001_create_todos.sql @@ -1,7 +1,7 @@ -- +goose Up CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE TABLE todos ( +CREATE TABLE IF NOT EXISTS todos ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), title VARCHAR(255) NOT NULL, description TEXT NOT NULL DEFAULT '', @@ -11,9 +11,9 @@ CREATE TABLE todos ( deleted_at TIMESTAMP WITH TIME ZONE ); -CREATE INDEX idx_todos_completed ON todos(completed) WHERE deleted_at IS NULL; -CREATE INDEX idx_todos_created_at ON todos(created_at DESC) WHERE deleted_at IS NULL; -CREATE INDEX idx_todos_deleted_at ON todos(deleted_at) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_todos_completed ON todos(completed) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_todos_created_at ON todos(created_at DESC) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_todos_deleted_at ON todos(deleted_at) WHERE deleted_at IS NULL; -- +goose Down DROP TABLE IF EXISTS todos; diff --git a/migrations/002_create_rbac_tables.sql b/migrations/002_create_rbac_tables.sql index c603c72..00bcc1d 100644 --- a/migrations/002_create_rbac_tables.sql +++ b/migrations/002_create_rbac_tables.sql @@ -1,6 +1,6 @@ -- +goose Up -CREATE TABLE roles ( +CREATE TABLE IF NOT EXISTS roles ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), name VARCHAR(100) NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', @@ -9,7 +9,7 @@ CREATE TABLE roles ( deleted_at TIMESTAMP WITH TIME ZONE ); -CREATE TABLE permissions ( +CREATE TABLE IF NOT EXISTS permissions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), name VARCHAR(100) NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', @@ -20,27 +20,27 @@ CREATE TABLE permissions ( deleted_at TIMESTAMP WITH TIME ZONE ); -CREATE TABLE user_roles ( +CREATE TABLE IF NOT EXISTS user_roles ( user_id UUID NOT NULL, role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, role_id) ); -CREATE TABLE role_permissions ( +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, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), PRIMARY KEY (role_id, permission_id) ); -CREATE INDEX idx_roles_name ON roles(name) WHERE deleted_at IS NULL; -CREATE INDEX idx_permissions_name ON permissions(name) WHERE deleted_at IS NULL; -CREATE INDEX idx_permissions_resource ON permissions(resource) WHERE deleted_at IS NULL; -CREATE INDEX idx_user_roles_user_id ON user_roles(user_id); -CREATE INDEX idx_user_roles_role_id ON user_roles(role_id); -CREATE INDEX idx_role_permissions_role_id ON role_permissions(role_id); -CREATE INDEX idx_role_permissions_permission_id ON role_permissions(permission_id); +CREATE INDEX IF NOT EXISTS idx_roles_name ON roles(name) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_permissions_name ON permissions(name) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_permissions_resource ON permissions(resource) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id); +CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id); +CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id); -- +goose Down diff --git a/migrations/003_create_users_and_refresh_tokens.sql b/migrations/003_create_users_and_refresh_tokens.sql index 7138a4f..9f3e20e 100644 --- a/migrations/003_create_users_and_refresh_tokens.sql +++ b/migrations/003_create_users_and_refresh_tokens.sql @@ -1,6 +1,6 @@ -- +goose Up -CREATE TABLE users ( +CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), email VARCHAR(255) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, @@ -11,7 +11,7 @@ CREATE TABLE users ( deleted_at TIMESTAMP WITH TIME ZONE ); -CREATE TABLE refresh_tokens ( +CREATE TABLE IF NOT EXISTS refresh_tokens ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token VARCHAR(500) NOT NULL UNIQUE, @@ -22,11 +22,11 @@ CREATE TABLE refresh_tokens ( deleted_at TIMESTAMP WITH TIME ZONE ); -CREATE INDEX idx_users_email ON users(email) WHERE deleted_at IS NULL; -CREATE INDEX idx_users_deleted_at ON users(deleted_at) WHERE deleted_at IS NULL; -CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); -CREATE INDEX idx_refresh_tokens_token ON refresh_tokens(token) WHERE deleted_at IS NULL; -CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens(expires_at) WHERE deleted_at IS NULL; -- +goose Down diff --git a/migrations/005_create_audit_and_error_logs.sql b/migrations/005_create_audit_and_error_logs.sql index 4011480..aa10ec0 100644 --- a/migrations/005_create_audit_and_error_logs.sql +++ b/migrations/005_create_audit_and_error_logs.sql @@ -1,6 +1,6 @@ -- +goose Up -CREATE TABLE audit_logs ( +CREATE TABLE IF NOT EXISTS audit_logs ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), request_id VARCHAR(100) NOT NULL, user_id UUID, @@ -16,7 +16,7 @@ CREATE TABLE audit_logs ( created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ); -CREATE TABLE error_logs ( +CREATE TABLE IF NOT EXISTS error_logs ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), request_id VARCHAR(100) NOT NULL, user_id UUID, @@ -35,15 +35,15 @@ CREATE TABLE error_logs ( created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ); -CREATE INDEX idx_audit_logs_request_id ON audit_logs(request_id); -CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id) WHERE user_id IS NOT NULL; -CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); -CREATE INDEX idx_audit_logs_method_path ON audit_logs(method, path); +CREATE INDEX IF NOT EXISTS idx_audit_logs_request_id ON audit_logs(request_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id) WHERE user_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_audit_logs_method_path ON audit_logs(method, path); -CREATE INDEX idx_error_logs_request_id ON error_logs(request_id); -CREATE INDEX idx_error_logs_level ON error_logs(level); -CREATE INDEX idx_error_logs_created_at ON error_logs(created_at); -CREATE INDEX idx_error_logs_status_code ON error_logs(status_code); +CREATE INDEX IF NOT EXISTS idx_error_logs_request_id ON error_logs(request_id); +CREATE INDEX IF NOT EXISTS idx_error_logs_level ON error_logs(level); +CREATE INDEX IF NOT EXISTS idx_error_logs_created_at ON error_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_error_logs_status_code ON error_logs(status_code); -- +goose Down diff --git a/migrations/006_add_email_verification.sql b/migrations/006_add_email_verification.sql new file mode 100644 index 0000000..304e50c --- /dev/null +++ b/migrations/006_add_email_verification.sql @@ -0,0 +1,20 @@ +-- +goose Up +ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT false; +ALTER TABLE users ADD COLUMN email_verify_token VARCHAR(255); +ALTER TABLE users ADD COLUMN email_verify_expires TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN password_reset_token VARCHAR(255); +ALTER TABLE users ADD COLUMN password_reset_expires TIMESTAMPTZ; + +UPDATE users SET email_verified = true WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_users_email_verify_token ON users(email_verify_token) WHERE email_verify_token IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_password_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL; + +-- +goose Down +DROP INDEX IF EXISTS idx_users_email_verify_token; +DROP INDEX IF EXISTS idx_users_password_reset_token; +ALTER TABLE users DROP COLUMN IF EXISTS email_verified; +ALTER TABLE users DROP COLUMN IF EXISTS email_verify_token; +ALTER TABLE users DROP COLUMN IF EXISTS email_verify_expires; +ALTER TABLE users DROP COLUMN IF EXISTS password_reset_token; +ALTER TABLE users DROP COLUMN IF EXISTS password_reset_expires; diff --git a/migrations/007_create_tenants.sql b/migrations/007_create_tenants.sql new file mode 100644 index 0000000..833883f --- /dev/null +++ b/migrations/007_create_tenants.sql @@ -0,0 +1,14 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + domain VARCHAR(255), + settings JSONB NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- +goose Down +DROP TABLE IF EXISTS tenants; diff --git a/migrations/008_normalize_users.sql b/migrations/008_normalize_users.sql new file mode 100644 index 0000000..69593b2 --- /dev/null +++ b/migrations/008_normalize_users.sql @@ -0,0 +1,148 @@ +-- +goose Up + +-- Add new columns to users before data migration +ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(36) NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ; + +-- user_credentials +CREATE TABLE IF NOT EXISTS user_credentials ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + password_hash VARCHAR(255) NOT NULL DEFAULT '', + last_login_at TIMESTAMPTZ +); + +-- user_security +CREATE TABLE IF NOT EXISTS user_security ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + login_attempts INTEGER NOT NULL DEFAULT 0, + locked_until TIMESTAMPTZ, + mfa_enabled BOOLEAN NOT NULL DEFAULT false, + mfa_secret VARCHAR(255) +); + +-- user_tokens +CREATE TABLE IF NOT EXISTS user_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_type VARCHAR(50) NOT NULL, + token_hash VARCHAR(255), + expires_at TIMESTAMPTZ, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_user_tokens_user_id ON user_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_user_tokens_token_hash ON user_tokens(token_hash); + +-- user_profiles +CREATE TABLE IF NOT EXISTS user_profiles ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + first_name VARCHAR(255) NOT NULL DEFAULT '', + last_name VARCHAR(255) NOT NULL DEFAULT '', + phone VARCHAR(50), + avatar_url TEXT, + timezone VARCHAR(50) NOT NULL DEFAULT 'UTC', + locale VARCHAR(10) NOT NULL DEFAULT 'en', + bio TEXT +); + +-- user_addresses +CREATE TABLE IF NOT EXISTS user_addresses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + label VARCHAR(100) NOT NULL DEFAULT '', + is_default BOOLEAN NOT NULL DEFAULT false, + street VARCHAR(255) NOT NULL DEFAULT '', + city VARCHAR(100) NOT NULL DEFAULT '', + state VARCHAR(100) NOT NULL DEFAULT '', + postal_code VARCHAR(20) NOT NULL DEFAULT '', + country VARCHAR(100) NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_user_addresses_user_id ON user_addresses(user_id); + +-- user_sessions +CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + refresh_token_hash VARCHAR(255), + device_info VARCHAR(500) NOT NULL DEFAULT '', + ip_address VARCHAR(45) NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); + +-- user_social_links +CREATE TABLE IF NOT EXISTS user_social_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider VARCHAR(50) NOT NULL, + provider_id VARCHAR(255) NOT NULL, + provider_email VARCHAR(255), + avatar_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(provider, provider_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_social_links_user_id ON user_social_links(user_id); + +-- user_preferences +CREATE TABLE IF NOT EXISTS user_preferences ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + preferences JSONB NOT NULL DEFAULT '{}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Drop old columns from users +ALTER TABLE users DROP COLUMN IF EXISTS password_hash; +ALTER TABLE users DROP COLUMN IF EXISTS login_attempts; +ALTER TABLE users DROP COLUMN IF EXISTS locked_until; +ALTER TABLE users DROP COLUMN IF EXISTS verification_token; +ALTER TABLE users DROP COLUMN IF EXISTS verification_token_expires_at; +ALTER TABLE users DROP COLUMN IF EXISTS reset_token; +ALTER TABLE users DROP COLUMN IF EXISTS reset_token_expires_at; +ALTER TABLE users DROP COLUMN IF EXISTS last_login_at; + +-- +goose Down + +-- Add back old columns +ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255); +ALTER TABLE users ADD COLUMN IF NOT EXISTS login_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS verification_token VARCHAR(255); +ALTER TABLE users ADD COLUMN IF NOT EXISTS verification_token_expires_at TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS reset_token VARCHAR(255); +ALTER TABLE users ADD COLUMN IF NOT EXISTS reset_token_expires_at TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; + +-- Restore data from new tables +UPDATE users SET + password_hash = uc.password_hash, + last_login_at = uc.last_login_at +FROM user_credentials uc +WHERE users.id = uc.user_id; + +UPDATE users SET + login_attempts = us.login_attempts, + locked_until = us.locked_until +FROM user_security us +WHERE users.id = us.user_id; + +-- Drop new tables +DROP TABLE IF EXISTS user_social_links; +DROP TABLE IF EXISTS user_sessions; +DROP TABLE IF EXISTS user_addresses; +DROP TABLE IF EXISTS user_preferences; +DROP TABLE IF EXISTS user_profiles; +DROP TABLE IF EXISTS user_tokens; +DROP TABLE IF EXISTS user_security; +DROP TABLE IF EXISTS user_credentials; + +-- Drop columns added by this migration +ALTER TABLE users DROP COLUMN IF EXISTS email_verified_at; +ALTER TABLE users DROP COLUMN IF EXISTS tenant_id; diff --git a/migrations/009_add_tenant_id.sql b/migrations/009_add_tenant_id.sql new file mode 100644 index 0000000..074e862 --- /dev/null +++ b/migrations/009_add_tenant_id.sql @@ -0,0 +1,42 @@ +-- +goose Up + +-- Add tenant_id to existing tables +ALTER TABLE todos ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(36) NOT NULL DEFAULT ''; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(36) NOT NULL DEFAULT ''; +ALTER TABLE permissions ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(36) NOT NULL DEFAULT ''; +ALTER TABLE user_roles ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(36) NOT NULL DEFAULT ''; +ALTER TABLE role_permissions ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(36) NOT NULL DEFAULT ''; +ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(36) NOT NULL DEFAULT ''; +ALTER TABLE error_logs ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(36) NOT NULL DEFAULT ''; + +-- Composite indexes (tenant_id, created_at) for tables with created_at +CREATE INDEX IF NOT EXISTS idx_todos_tenant_created ON todos(tenant_id, created_at); +CREATE INDEX IF NOT EXISTS idx_roles_tenant_created ON roles(tenant_id, created_at); +CREATE INDEX IF NOT EXISTS idx_permissions_tenant_created ON permissions(tenant_id, created_at); +CREATE INDEX IF NOT EXISTS idx_user_roles_tenant_created ON user_roles(tenant_id, created_at); +CREATE INDEX IF NOT EXISTS idx_role_permissions_tenant_created ON role_permissions(tenant_id, created_at); +CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_created ON audit_logs(tenant_id, created_at); +CREATE INDEX IF NOT EXISTS idx_error_logs_tenant_created ON error_logs(tenant_id, created_at); + +-- Tenant-scoped indexes on users and tenants +CREATE INDEX IF NOT EXISTS idx_users_tenant_email ON users(tenant_id, email); + +-- +goose Down + +ALTER TABLE todos DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE roles DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE permissions DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE user_roles DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE role_permissions DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE audit_logs DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE error_logs DROP COLUMN IF EXISTS tenant_id; + +DROP INDEX IF EXISTS idx_todos_tenant_created; +DROP INDEX IF EXISTS idx_roles_tenant_created; +DROP INDEX IF EXISTS idx_permissions_tenant_created; +DROP INDEX IF EXISTS idx_user_roles_tenant_created; +DROP INDEX IF EXISTS idx_role_permissions_tenant_created; +DROP INDEX IF EXISTS idx_audit_logs_tenant_created; +DROP INDEX IF EXISTS idx_error_logs_tenant_created; +DROP INDEX IF EXISTS idx_users_tenant_email; +DROP INDEX IF EXISTS idx_tenants_tenant_slug; diff --git a/migrations/010_add_casbin_rule_table.sql b/migrations/010_add_casbin_rule_table.sql new file mode 100644 index 0000000..a4adaa2 --- /dev/null +++ b/migrations/010_add_casbin_rule_table.sql @@ -0,0 +1,16 @@ +-- +goose Up +CREATE TABLE casbin_rule ( + id SERIAL PRIMARY KEY, + ptype VARCHAR(100) NOT NULL, + v0 VARCHAR(255) DEFAULT '', + v1 VARCHAR(255) DEFAULT '', + v2 VARCHAR(255) DEFAULT '', + v3 VARCHAR(255) DEFAULT '', + v4 VARCHAR(255) DEFAULT '', + v5 VARCHAR(255) DEFAULT '' +); + +CREATE INDEX idx_casbin_rule_ptype ON casbin_rule(ptype); + +-- +goose Down +DROP TABLE IF EXISTS casbin_rule; diff --git a/sqlc.yaml b/sqlc.yaml index f2eb418..f5048bb 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -1,7 +1,7 @@ version: "2" sql: - engine: "postgresql" - queries: "internal/todo/infrastructure/persistence/queries.sql" + queries: "internal/todo/infrastructure/persistence/queries/todo.sql" schema: "migrations" gen: go: @@ -10,3 +10,79 @@ sql: sql_package: "database/sql" emit_json_tags: true emit_empty_slices: true + emit_pointers_for_null_types: true + overrides: + - db_type: "jsonb" + go_type: + type: "[]byte" + nullable: true + + - engine: "postgresql" + queries: "internal/authentication/infrastructure/persistence/queries/queries.sql" + schema: "migrations" + gen: + go: + package: "sqlc" + out: "internal/authentication/infrastructure/persistence/sqlc" + sql_package: "database/sql" + emit_json_tags: true + emit_empty_slices: true + emit_pointers_for_null_types: true + overrides: + - db_type: "jsonb" + go_type: + type: "[]byte" + nullable: true + + - engine: "postgresql" + queries: "internal/authorization/infrastructure/persistence/queries/queries.sql" + schema: "migrations" + gen: + go: + package: "sqlc" + out: "internal/authorization/infrastructure/persistence/sqlc" + sql_package: "database/sql" + emit_json_tags: true + emit_empty_slices: true + emit_pointers_for_null_types: true + overrides: + - db_type: "jsonb" + go_type: + type: "[]byte" + nullable: true + + - engine: "postgresql" + queries: "internal/user/infrastructure/persistence/queries/queries.sql" + schema: "migrations" + gen: + go: + package: "sqlc" + out: "internal/user/infrastructure/persistence/sqlc" + sql_package: "database/sql" + emit_json_tags: true + emit_empty_slices: true + emit_pointers_for_null_types: true + overrides: + - db_type: "jsonb" + go_type: + type: "[]byte" + nullable: true + + - engine: "postgresql" + queries: "internal/tenant/infrastructure/persistence/queries/queries.sql" + schema: "migrations" + gen: + go: + package: "sqlc" + out: "internal/tenant/infrastructure/persistence/sqlc" + sql_package: "database/sql" + emit_json_tags: true + emit_empty_slices: true + emit_pointers_for_null_types: true + overrides: + - db_type: "jsonb" + go_type: + type: "[]byte" + nullable: true + + diff --git a/test/integration/integration_test.go b/test/integration/integration_test.go index 7a518be..2fa8929 100644 --- a/test/integration/integration_test.go +++ b/test/integration/integration_test.go @@ -1,74 +1,94 @@ package integration import ( - "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" + "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestHealthEndpoint(t *testing.T) { - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func TestHealthEndpoint_JSONStructure(t *testing.T) { + r := chi.NewRouter() + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"ok"}`)) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) }) req := httptest.NewRequest(http.MethodGet, "/health", nil) - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, rr.Code) - assert.Contains(t, rr.Body.String(), "ok") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + var resp struct { + Status string `json:"status"` + } + err := json.NewDecoder(rec.Body).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, "ok", resp.Status) +} + +func TestReadyEndpoint_JSONStructure(t *testing.T) { + r := chi.NewRouter() + r.Get("/ready", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ready"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/ready", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp struct { + Status string `json:"status"` + } + err := json.NewDecoder(rec.Body).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, "ready", resp.Status) } -func TestRegisterValidation(t *testing.T) { - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req struct { - Email string `json:"email"` - Password string `json:"password"` - Name string `json:"name"` - } - json.NewDecoder(r.Body).Decode(&req) - - if req.Email == "" || req.Password == "" { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": false, - "error": map[string]string{ - "code": "VALIDATION_ERROR", - "message": "email and password are required", - }, - }) - return - } - - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": true, - "data": map[string]string{ - "access_token": "test-token", - }, - }) +func TestLiveEndpoint_JSONStructure(t *testing.T) { + r := chi.NewRouter() + r.Get("/live", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "alive"}) }) - body := map[string]string{"email": "", "password": ""} - bodyBytes, _ := json.Marshal(body) - req := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) + req := httptest.NewRequest(http.MethodGet, "/live", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) - assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Equal(t, http.StatusOK, rec.Code) + + var resp struct { + Status string `json:"status"` + } + err := json.NewDecoder(rec.Body).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, "alive", resp.Status) +} + +func TestHealthEndpoint_MethodNotAllowed(t *testing.T) { + r := chi.NewRouter() + r.Get("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) - body = map[string]string{"email": "test@example.com", "password": "secret123", "name": "Test"} - bodyBytes, _ = json.Marshal(body) - req = httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - rr = httptest.NewRecorder() - handler.ServeHTTP(rr, req) + req := httptest.NewRequest(http.MethodPost, "/health", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) - assert.Equal(t, http.StatusCreated, rr.Code) + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) }