diff --git a/backend/onboarding/.dockerignore b/backend/onboarding/.dockerignore new file mode 100644 index 0000000..d2d69d3 --- /dev/null +++ b/backend/onboarding/.dockerignore @@ -0,0 +1,7 @@ +*.json +*.md +/migrations +.env +*.yaml +*.yml +.gitignore \ No newline at end of file diff --git a/backend/onboarding/.gitignore b/backend/onboarding/.gitignore new file mode 100644 index 0000000..897c28c --- /dev/null +++ b/backend/onboarding/.gitignore @@ -0,0 +1,48 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go build command +/property-tax-onboarding + +# Go workspace file +go.work +go.sum + +# Environment variables +.env + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +*.log +logs/ + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Dependency directories +vendor/ +node_modules/ \ No newline at end of file diff --git a/backend/onboarding/Dockerfile b/backend/onboarding/Dockerfile new file mode 100644 index 0000000..0f81574 --- /dev/null +++ b/backend/onboarding/Dockerfile @@ -0,0 +1,36 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +WORKDIR /app + +# Copy go.mod and go.sum files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy the source code +COPY . . + +# Build the application (main.go is in cmd/server/) +RUN CGO_ENABLED=0 GOOS=linux go build -o property-service ./cmd/server/main.go + +# Final stage +FROM alpine:3.19 + +WORKDIR /app + +# Install ca-certificates for HTTPS requests +RUN apk --no-cache add ca-certificates + +# Copy the binary from the builder stage +COPY --from=builder /app/property-service . + +# Set permissions +RUN chmod +x /app/property-service + +# Expose port 8080 (default property service port) +EXPOSE 8080 + +# Set the entry point +ENTRYPOINT ["./property-service"] \ No newline at end of file diff --git a/backend/onboarding/README.md b/backend/onboarding/README.md new file mode 100644 index 0000000..5c6b3ee --- /dev/null +++ b/backend/onboarding/README.md @@ -0,0 +1,400 @@ +# Property Tax Onboarding Service Documentation + +## Table of Contents + +1. [Overview](#1-overview) +2. [Features](#2-features) +3. [Architecture & Design](#3-architecture--design) +4. [Authentication & Authorization](#4-authentication--authorization) +5. [API Specification](#5-api-specification) +6. [Configuration](#6-configuration) +7. [Project Structure](#7-project-structure) +8. [Getting Started](#8-getting-started) +9. [Development Guidelines](#9-development-guidelines) +10. [API Documentation](#10-api-documentation) +11. [License](#11-license) +12. [Support](#12-support) + + +## 1. Overview + +### Purpose +The Property Tax Onboarding Service is a Go-based microservice responsible for user profile management in the property tax system. It handles complete user lifecycle operations including registration, profile management, and user data persistence while integrating seamlessly with Keycloak for identity management. + +### Scope +- Part of the **Property Tax Management System** +- Handles user onboarding and profile management +- Integrates with Keycloak for user creation and role assignment +- Provides RESTful APIs for user operations +- Manages user data persistence in PostgreSQL + +## Technology Stack + +- **Language**: Go 1.21+ +- **Web Framework**: Gin +- **ORM**: GORM +- **Database Driver**: pgx/v5 +- **Logging**: Logrus +- **Validation**: go-playground/validator +- **Configuration**: Environment variables with godotenv +- **UUID**: Google UUID library +- **Development**: Local development setup + +## 2. Features + +- **User Profile Management**: Complete CRUD operations for user profiles with profile and address management +- **Zone & Ward Mapping**: Assign users to multiple zones and wards using PostgreSQL array types +- **Advanced Filtering**: Filter users by role, status, email, username, phone number, and wards +- **Keycloak Integration**: Seamless integration with Keycloak for user creation and role assignment +- **RESTful API**: Well-structured REST endpoints following best practices +- **Database Integration**: PostgreSQL database with GORM ORM and pgx driver +- **Layered Architecture**: Clean separation of concerns with distinct layers (Handler → Service → Repository) +- **Transaction Support**: Atomic operations for complex updates using GORM transactions +- **Comprehensive Logging**: Structured logging with Logrus +- **Error Handling**: Robust error handling with custom error types and validation +- **Pagination Support**: Efficient pagination for list endpoints + +## 3. Architecture & Design + +### High-Level Architecture +``` +┌─────────────────────────────────────────┐ +│ API Gateway │ ← Handles Auth/AuthZ +├─────────────────────────────────────────┤ +│ HTTP Handlers │ ← Request Processing +├─────────────────────────────────────────┤ +│ Service Layer │ ← Business Logic +├─────────────────────────────────────────┤ +│ Repository Layer │ ← Data Access +├─────────────────────────────────────────┤ +│ Database (PostgreSQL) │ ← Data Persistence +└─────────────────────────────────────────┘ +``` + +### Design Patterns + +#### 1. Layered Architecture +The service is structured into distinct layers, each with a clear responsibility: +- **Handler Layer** (`internal/handlers/user_handler.go`): Handles HTTP requests, parses input, and returns responses. +- **Service Layer** (`internal/services/user_service.go`): Contains business logic, orchestrates workflows, and coordinates between handlers and repositories. +- **Repository Layer** (`internal/repositories/users_repo.go`): Encapsulates all database access and persistence logic. +- **Model Layer** (`internal/models/user.go`, `internal/models/database_models.go`): Defines data structures and DTOs for both API and database. +This separation improves maintainability, testability, and scalability. + +#### 2. RESTful API Design +The service exposes endpoints that follow RESTful conventions: +- Uses standard HTTP methods (`GET`, `POST`, `PUT`, `DELETE`). +- Resource-oriented URLs (e.g., `/api/v1/users`). +- Returns appropriate HTTP status codes and standardized JSON responses (`pkg/response/response.go`). +- Supports filtering, pagination, and role-based access via query parameters and payloads. + +#### 3. Repository Pattern +All database operations are abstracted behind repository interfaces (`internal/repositories/interfaces.go`), allowing: +- Easy swapping of database implementations. +- Centralized query logic. +- Simplified mocking for tests. + +#### 4. Dependency Injection +Dependencies (e.g., repositories, services) are injected into handlers and services at runtime, typically in the application bootstrap (`cmd/server/main.go`). This enables: +- Loose coupling between components. +- Easier unit testing with mocks/stubs. + +#### 5. Standardized API Response Pattern +All API responses use a consistent structure, defined in [`pkg/response/response.go`](pkg/response/response.go ): +- `success`: Boolean indicating operation result. +- `message`: Human-readable status. +- `data`: Payload (if any). +- `errors`: List of error messages (if any). +This ensures predictable client-side handling and easier error tracking. + +#### 6. External Service Integration Pattern +Integration with Keycloak for user management is encapsulated in a dedicated service (`internal/services/keycloak_service.go`), following the "service gateway" pattern. This: +- Isolates external API logic. +- Allows for retries, error handling, and future replacement. + +#### 7. Validation Pattern +Input validation is performed using the go-playground/validator library, with validation logic centralized in the validator package (`internal/validator/user_validation_service.go`). This ensures: +- Consistent validation rules across endpoints. +- Clear error messages for invalid input. + +#### 8. Soft Delete Pattern +User records are soft-deleted using a `deleted_at` timestamp field, preserving data for audit and recovery (`internal/models/database_models.go`). + +### Service Dependencies +- **Keycloak Server**: User creation and role management +- **PostgreSQL Database**: Primary data storage +- **API Gateway**: Authentication and authorization (external) + +### Key Components +- **Handlers**: HTTP request/response processing (`internal/handlers/`) +- **Services**: Business logic and orchestration (`internal/services/`) +- **Repositories**: Data access layer (`internal/repositories/`) +- **Models**: Data structures and DTOs (`internal/models/`) +- **Config**: Configuration management (`internal/config/`) +- **Routes**: API route definitions (`internal/routes/`) + + + +## 4. Authentication & Authorization + +This onboarding service does not require authentication or authorization. All API endpoints are accessible without tokens or credentials. No JWT, OAuth, or other authentication mechanisms are enforced for any endpoint. + +## 5. API Specification + +### Base URL +``` +http://localhost:8080/api/v1 +``` + +### API Endpoints + +| Method | Endpoint | Description | +|--------|-------------------------------|---------------------------------------------| +| GET | /api/v1/users | Get all users with filters, pagination, and search | +| POST | /api/v1/users | Create a new user (citizen, agent, service manager, commissioner) | +| GET | /api/v1/users/{id} | Retrieve a user by their ID | +| PUT | /api/v1/users/{id} | Update complete user information | +| DELETE | /api/v1/users/{id} | Soft delete user by ID | +| GET | /api/v1/users/by-role/{role} | Get users by specific role | +| GET | /health | Service health check endpoint | + + + +## 6. Configuration + +### Environment Variables +```bash +# Database Configuration +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=password +DB_NAME=property_tax_db +DB_SSL_MODE=disable + +# Server Configuration +SERVER_PORT=8080 +GIN_MODE=debug + +# Keycloak Configuration +KEYCLOAK_BASE_URL=http://localhost:8080/auth +KEYCLOAK_REALM=property-tax-realm +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD=admin +KEYCLOAK_CLIENT_ID=property-tax-client +KEYCLOAK_CLIENT_SECRET=your-client-secret + +# Logging Configuration +LOG_LEVEL=info +LOG_FORMAT=json + +# Application Configuration +APP_NAME=property-tax-onboarding +APP_VERSION=1.0.0 +``` + +### Configuration Structure +The service uses a structured configuration approach defined in `internal/config/config.go` with sections for: +- **DatabaseConfig**: Database connection settings +- **ServerConfig**: Server and Gin settings +- **KeycloakConfig**: Keycloak integration settings +- **LogConfig**: Logging configuration +- **AppConfig**: Application metadata + +### Sample Configuration File (.env) +```bash +# Copy this to .env and update values +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=your_password +DB_NAME=property_tax_db +DB_SSL_MODE=disable + +SERVER_PORT=8080 +GIN_MODE=release + +KEYCLOAK_BASE_URL=http://keycloak:8080/auth +KEYCLOAK_REALM=property-tax +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD=admin_password +KEYCLOAK_CLIENT_ID=property-tax-onboarding +KEYCLOAK_CLIENT_SECRET=client_secret + +LOG_LEVEL=info +LOG_FORMAT=json +``` + +## 7. Project Structure + +``` +property-tax-onboarding/ +├── .gitignore +├── Dockerfile +├── go.mod +├── README.md +├── service-documentation 2.md +├── cmd/ +│ └── server/ +│ └── main.go +├── internal/ +│ ├── config/ +│ │ └── config.go +│ ├── constants/ +│ │ ├── errors_constants.go +│ │ └── logs_constants.go +│ ├── database/ +│ │ └── database.go +│ ├── errors/ +│ │ └── errors.go +│ ├── handlers/ +│ │ └── user_handler.go +│ ├── middleware/ +│ │ └── cors.go +│ ├── models/ +│ │ ├── user.go +│ │ └── database_models.go +│ ├── repositories/ +│ │ ├── users_repo.go +│ │ ├── transaction.go +│ │ └── ... +│ ├── routes/ +│ │ └── routes.go +│ ├── services/ +│ │ ├── user_service.go +│ │ ├── keycloak_service.go +│ │ └── ... +│ ├── utils/ +│ │ ├── mdms_utils.go +│ │ ├── keycloak_utils.go +│ │ └── ... +│ └── validator/ +│ └── user_validation_service.go +├── migrations/ +│ └── README.md +├── pkg/ +│ ├── logger/ +│ │ └── logger.go +│ └── response/ +│ └── response.go +├── postman/ +│ └── new_onboarding.postman_collection.json +``` + +- `cmd/server/main.go`: Application entry point +- `internal/handlers/`: HTTP request handlers +- `internal/services/`: Business logic and orchestration +- `internal/repositories/`: Data access layer +- `internal/models/`: Data models and DTOs +- `internal/config/`: Configuration management +- `internal/routes/`: API route definitions +- `internal/middleware/`: Custom middleware (e.g., CORS) +- `internal/utils/`: Utility functions (e.g., MDMS, Keycloak helpers) +- `internal/validator/`: Input validation logic +- `pkg/logger/`: Logging utilities +- `pkg/response/`: Standardized API responses +- `migrations/`: Database migration scripts and docs +- `postman/`: Postman collections for API testing + +For more details, see the respective files and folders. + +## 8. Getting Started + +Follow these steps to set up and run the Property Tax Onboarding Service for local development or in a containerized environment. + +### Prerequisites +- Go 1.24 or higher +- PostgreSQL 12 or higher +- Docker (optional, for containerized setup) + +### Local Development Setup +1. **Clone the repository** + ```bash + git clone + cd property-tax-onboarding + ``` +2. **Install Go dependencies** + ```bash + go mod download + ``` +3. **Configure environment variables** + Copy the example environment file and update values as needed: + ```bash + cp .env.example .env + # Edit .env with your database and service URLs + ``` +4. **Set up the PostgreSQL database** + - Ensure PostgreSQL is running and accessible as per your .env configuration. + - Create the database and user if not already present. +5. **Run database migrations** + Apply schema and seed data: + ```bash + go run migrations/*.go + # Or use a migration tool if provided + ``` +6. **Start the service** + ```bash + go run cmd/server/main.go + ``` + The service will be available at http://localhost:8080 + +## 9. Development Guidelines + +### Code Style +- Follow Go best practices and idiomatic Go style (gofmt, golint). +- Use clear, descriptive names for variables, functions, and files. +- Keep functions small and focused on a single responsibility. +- Use comments to explain complex logic or business rules. + +### Project Structure +- Place new features in the appropriate layer (handler, service, repository, model). +- Avoid business logic in handlers; keep it in services. +- Use interfaces for repositories and services to enable mocking and testing. + +### Version Control +- Use feature branches for new work (e.g., feature/your-feature-name). +- Write clear, concise commit messages. +- Rebase and squash commits before merging to main/develop. + +### Testing +- Write unit tests for all business logic and data access code. +- Use table-driven tests for Go functions. +- Mock external dependencies (database, Keycloak) in tests. +- Run tests locally before pushing changes. + +### Pull Requests +- Ensure all tests pass before submitting a PR. +- Request reviews from at least one other team member. +- Address review comments promptly. + +### Documentation +- Update documentation (README, service-documentation) for new features or changes. +- Document public APIs and exported functions. + +### Security & Secrets +- Never commit secrets, passwords, or sensitive data to the repository. +- Use environment variables or secret management tools for credentials. + +### Issue Tracking +- Use the issue tracker to report bugs, request features, or track tasks. + +--- +For more details, refer to the team wiki or contact the maintainers. + +## 10. API Documentation + +Deployed Swagger UI — View and interact with the live API documentation: + +[http://10.232.161.103:30116/swagger/index.html#](http://10.232.161.103:30116/swagger/index.html#) + + +## 11. License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## 12. Support + +For support and questions: +- Create an issue in the GitHub repository +- Contact the development team +- Check the API documentation in `/docs` \ No newline at end of file diff --git a/backend/onboarding/cmd/server/main.go b/backend/onboarding/cmd/server/main.go new file mode 100644 index 0000000..24c3b2b --- /dev/null +++ b/backend/onboarding/cmd/server/main.go @@ -0,0 +1,106 @@ +// Entry point for the Property Tax Onboarding Service. +// This file bootstraps the application, sets up dependencies, middleware, routes, and starts the HTTP server. +package main + +import ( + "log" + "property-tax-onboarding/internal/config" + "property-tax-onboarding/internal/database" + "property-tax-onboarding/internal/handlers" + "property-tax-onboarding/internal/middleware" + "property-tax-onboarding/internal/repositories" + "property-tax-onboarding/internal/routes" + "property-tax-onboarding/internal/scheduler" + "property-tax-onboarding/internal/services" + "property-tax-onboarding/internal/validator" + "property-tax-onboarding/pkg/logger" + "strings" + + "github.com/gin-gonic/gin" +) + +func main() { + // Initialize the structured logger (Logrus) + logger.InitLogger() + + // Load application configuration from environment variables or .env file + cfg := config.GetConfig() + + // Connect to PostgreSQL database using GORM + db := database.Connect(cfg) + sqlDB, err := db.DB() + if err != nil { + logger.Fatal("Failed to get database instance", "error", err) + } + // Ensure database connection is closed on shutdown + defer sqlDB.Close() + + // Set Gin web framework mode (debug/release) + gin.SetMode(cfg.GinMode) + + // Initialize the user repository (data access layer) + userRepo := repositories.NewPostgreSQLUserRepository(db) + + // Initialize Keycloak service for identity management + keycloakService := services.NewKeycloakService( + cfg.KeycloakBaseURL, + cfg.TokenURL, + cfg.UserURL, + cfg.AssignRoleURL, + cfg.RoleURL, + cfg.RolesURL, + cfg.DeleteURL, + cfg.KeycloakAdminUsername, + cfg.KeycloakAdminPassword, + cfg.KeycloakClientID, + cfg.KeycloakClientSecret, + cfg.KeycloakRealm, + userRepo, + ) + + // Initialize user validation service (input validation) + validationService := validator.NewUserValidationService(userRepo, db) + + zoneMappingRepo := repositories.NewPostgreSQLZoneMappingRepository(db) + + // Initialize user service (business logic layer) + userService := services.NewUserService(keycloakService, userRepo, validationService, zoneMappingRepo) + + // Initialize HTTP handlers (request/response layer) + userHandler := handlers.NewUserHandler(userService) + + // Create a new Gin router instance + router := gin.New() + + // Configure and add CORS middleware + allowedOrigins := strings.Split(cfg.CORSAllowedOrigins, ",") + allowedMethods := strings.Split(cfg.CORSAllowedMethods, ",") + allowedHeaders := strings.Split(cfg.CORSAllowedHeaders, ",") + + corsConfig := middleware.CORSConfig{ + AllowedOrigins: allowedOrigins, + AllowedMethods: allowedMethods, + AllowedHeaders: allowedHeaders, + AllowCredentials: true, + MaxAge: 86400, // 24 hours + } + router.Use(middleware.CORSWithConfig(corsConfig)) + + // Add logging and recovery middleware + router.Use(gin.Logger()) + router.Use(gin.Recovery()) + + // Register all API routes (endpoints) + routes.SetupRoutes(router, userHandler) + + // Start the scheduler for user activation/deactivation + scheduler.ScheduleUserActivation(keycloakService, userRepo) + + // Start server + serverAddr := cfg.ServerHost + ":" + cfg.ServerPort + log.Printf("Starting property tax onboarding service on %s", serverAddr) + + if err := router.Run(serverAddr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/backend/onboarding/go.mod b/backend/onboarding/go.mod new file mode 100644 index 0000000..73b3964 --- /dev/null +++ b/backend/onboarding/go.mod @@ -0,0 +1,54 @@ +module property-tax-onboarding + +go 1.24.0 + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 + github.com/gin-gonic/gin v1.9.1 +) + +require ( + github.com/kr/text v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.6.1 // indirect +) + +require github.com/robfig/cron/v3 v3.0.1 + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.6.0 + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/lib/pq v1.10.9 + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 +) diff --git a/backend/onboarding/internal/config/config.go b/backend/onboarding/internal/config/config.go new file mode 100644 index 0000000..7377237 --- /dev/null +++ b/backend/onboarding/internal/config/config.go @@ -0,0 +1,134 @@ +// Package config provides application configuration management. +// It loads environment variables, supports .env files, and exposes a singleton Config struct. +package config + +import ( + "log" + "os" + "sync" + "github.com/joho/godotenv" + +) + +// instance holds the singleton Config instance. +// once ensures the config is loaded only once (thread-safe). +var ( + instance *Config + once sync.Once +) + +// Config holds all configuration values for the application. +// It is populated from environment variables or a .env file at startup. +type Config struct { + // Server config + ServerPort string + ServerHost string + GinMode string + + // Keycloak config + KeycloakBaseURL string + KeycloakAdminUsername string + KeycloakAdminPassword string + KeycloakClientID string + KeycloakClientSecret string + KeycloakRealm string + TokenURL string + UserURL string + AssignRoleURL string + RoleURL string + RolesURL string + DeleteURL string + + // Database config + DBHost string + DBPort string + DBUser string + DBPassword string + DBName string + DBSchema string + + // CORS config + CORSAllowedOrigins string + CORSAllowedMethods string + CORSAllowedHeaders string + + //MDMS config + MDMS_API_URL string +} + +// GetConfig returns the singleton instance of Config. +// Loads .env file if present, then populates Config from environment variables. +// Validates critical configuration values before returning. +func GetConfig() *Config { + once.Do(func() { + // Load .env file from the specified path + + err := godotenv.Load() + if err != nil { + log.Println("Warning: .env file not found at", "using system environment variables") + } + + // Initialize the Config instance with environment variables or defaults + instance = &Config{ + // Server config + ServerPort: getEnvOrDefault("SERVER_PORT", "8089"), + ServerHost: getEnvOrDefault("SERVER_HOST", "0.0.0.0"), + GinMode: getEnvOrDefault("GIN_MODE", "debug"), + + // Keycloak config + KeycloakBaseURL: getEnvOrDefault("KEYCLOAK_BASE_URL", ""), + KeycloakAdminUsername: getEnvOrDefault("KEYCLOAK_ADMIN_USER", ""), + KeycloakAdminPassword: getEnvOrDefault("KEYCLOAK_ADMIN_PASS", ""), + KeycloakClientID: getEnvOrDefault("KEYCLOAK_CLIENT_ID", ""), + KeycloakClientSecret: getEnvOrDefault("KEYCLOAK_CLIENT_SECRET", ""), + KeycloakRealm: getEnvOrDefault("KEYCLOAK_REALM", ""), + TokenURL: getEnvOrDefault("TOKEN_URL", ""), + UserURL: getEnvOrDefault("USER_URL", ""), + AssignRoleURL: getEnvOrDefault("ASSIGN_ROLE_URL", ""), + RoleURL: getEnvOrDefault("ROLE_URL", ""), + RolesURL: getEnvOrDefault("ROLES_URL", ""), + DeleteURL: getEnvOrDefault("DELETE_URL", ""), + + // Database config + DBHost: getEnvOrDefault("DB_HOST", ""), + DBPort: getEnvOrDefault("DB_PORT", ""), + DBUser: getEnvOrDefault("DB_USER", ""), + DBPassword: getEnvOrDefault("DB_PASSWORD", ""), + DBName: getEnvOrDefault("DB_NAME", ""), + DBSchema: getEnvOrDefault("DB_SCHEMA", "DIGIT3"), + + // CORS config + CORSAllowedOrigins: getEnvOrDefault("CORS_ALLOWED_ORIGINS", "*"), + CORSAllowedMethods: getEnvOrDefault("CORS_ALLOWED_METHODS", "GET,POST,PUT,DELETE,PATCH,OPTIONS"), + CORSAllowedHeaders: getEnvOrDefault("CORS_ALLOWED_HEADERS", "Origin,Content-Type,Accept,Authorization,X-Requested-With,X-Tenant-ID,X-User-ID"), + + //MDMS API + MDMS_API_URL: getEnvOrDefault("MDMS_API_URL", ""), + } + // Validate critical configuration values + validateConfig(instance) + }) + return instance +} + +// getEnvOrDefault retrieves the value of the environment variable or returns the default value if not set. +func getEnvOrDefault(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} + +// validateConfig verifies that required configuration fields are present. +// It returns an error describing missing or invalid settings instead of +// terminating the process directly (caller decides how to handle it). +func validateConfig(cfg *Config) { + if cfg.KeycloakBaseURL == "" || cfg.KeycloakAdminUsername == "" || cfg.KeycloakAdminPassword == "" { + log.Fatalf("Critical Keycloak configuration is missing. Please check environment variables.") + } + if cfg.DBHost == "" || cfg.DBPort == "" || cfg.DBUser == "" || cfg.DBPassword == "" || cfg.DBName == "" { + log.Fatalf("Critical database configuration is missing. Please check environment variables.") + } + log.Println("Configuration validated successfully") +} + diff --git a/backend/onboarding/internal/constants/errors_constants.go b/backend/onboarding/internal/constants/errors_constants.go new file mode 100644 index 0000000..23ae7e6 --- /dev/null +++ b/backend/onboarding/internal/constants/errors_constants.go @@ -0,0 +1,174 @@ +// Package constants defines error and status message constants for the onboarding service. +// This file centralizes all error, success, and service info messages for maintainability. +package constants + +// Keycloak service error messages +const ( + ErrEmptyUserID = "userID cannot be empty" + ErrEmptyRoleName = "roleName cannot be empty" + ErrEmptyUsername = "username cannot be empty" + ErrEmptyEmail = "email cannot be empty" + ErrCreateToken = "failed to create token request" + ErrRequestToken = "failed to request token" + ErrDecodeToken = "failed to decode token response" + ErrGetAdminToken = "failed to get admin token" + ErrMarshalUserData = "failed to marshal user data" + ErrCreateUserRequest = "failed to create user request" + ErrCreateUser = "failed to create user" + ErrNoLocationHeader = "no location header in create user response" + ErrInvalidLocation = "invalid location header format" + ErrGetRole = "failed to get role" + ErrMarshalRole = "failed to marshal role mapping" + ErrAssignRoleRequest = "failed to create role assignment request" + ErrAssignRole = "failed to assign role" + ErrGetUserRoles = "failed to get user roles" + ErrDeleteUserRequest = "failed to create delete request" + ErrDeleteUser = "failed to delete user" + ErrDeleteUserKeycloak = "failed to delete user from Keycloak" + ErrCreateRoleRequest = "failed to create role request" + ErrKeycloakAPIStatus = "keycloak API returned status" + ErrDecodeKeycloakUser = "failed to decode Keycloak user response" +) + +// User service error messages +const ( + ErrValidationFailed = "validation failed" + ErrCreateKeycloakUser = "failed to create user in Keycloak" + ErrAssignRoleToUser = "failed to assign role to user" + ErrBeginTransaction = "failed to begin database transaction" + ErrCreateUserFromRequest = "failed to create user from request" + ErrSaveUserToDatabase = "failed to save user to database" + ErrSaveUserProfile = "failed to save user profile to database" + ErrZoneValidationFailed = "zone validation failed" + ErrInsertZoneMappings = "failed to insert zone mappings" + ErrFetchZoneMappings = "failed to fetch zone mappings" + ErrFetchAddress = "failed to fetch address" + ErrUserNotFound = "user not found" + ErrUpdateUser = "failed to update user" + ErrGetUsersByRole = "failed to get users by role" + ErrGetUsersWithFilters = "failed to get users with filters" + ErrNoUsersFound = "no users found matching the specified filters" + ErrInvalidZone = "invalid zone" + ErrZoneNotFound = "zone not found in MDMS" + ErrWardNotAllowed = "ward not allowed for zone" + ErrUserRepositoryUnavailable = "unified user repository not available" + ErrKeycloakUserIDEmpty = "keycloak user ID cannot be empty" + ErrUpdateUserFailed = "failed to update user" + ErrGetUpdatedUserFailed = "failed to get updated user" + ErrFetchZoneDetailsMDMS = "failed to fetch zone details from MDMS" + ErrFetchKeycloakUser = "failed to fetch user from Keycloak" +) + +// Repository-specific error messages (user_repo.go) +const ( + ErrUserProfileNotFound = "user profile not found" + ErrRetrieveUserProfileFailed = "failed to retrieve user profile" + ErrRetrieveUserFailed = "failed to retrieve user" + ErrCheckUserExistenceFailed = "Failed to check user existence" + ErrDeleteAddressFailed = "Failed to delete address" + ErrDeleteUserProfileFailed = "Failed to delete user profile" + ErrDeleteUserFailed = "Failed to delete user" + ErrDeleteTransactionFailed = "Failed to complete delete transaction" + ErrAddressNotFound = "Address not found" + ErrRetrieveAddressFailed = "Failed to retrieve address" + ErrCountUsersFailed = "Failed to count users" + ErrRetrieveAddressIDFailed = "Failed to retrieve AddressID" + ErrCreateNewAddressFailed = "Failed to create new address" + ErrUpdateAddressIDFailed = "Failed to update AddressID" + ErrUpdateExistingAddressFailed = "Failed to update existing address" + ErrUpdateUserProfileFailed = "Failed to update user profile" + ErrCountUsersWithFiltersFailed = "Failed to count users with filters" + ErrRetrieveUsersWithFiltersFailed = "Failed to retrieve users with filters" + ErrCheckAdhaarExistsFailed = "Failed to check Aadhaar existence" + ErrCheckPhoneExistsFailed = "Failed to check phone number existence" + ErrDeleteZoneMappingFailed = "Failed to soft delete zone mappings" + ErrGetUsersToDisableFailed = "Failed to get users to disable" + ErrGetUsersToEnableFailed = "Failed to get users to enable" +) + +// Transaction-related error messages (transaction.go) +const ( + ErrCreateUserInTransaction = "Failed to create user in transaction" + ErrCreateUserProfileInTransaction = "Failed to create user profile in transaction" + ErrRollbackTransaction = "Failed to rollback transaction" + ErrCommitTransaction = "Failed to commit transaction" +) + +// MDMS utility error messages +const ( + ErrCreateMDMSRequest = "Failed to create MDMS request" + ErrCallMDMSAPI = "Failed to call MDMS API" + ErrMDMSAPINon200Status = "MDMS API returned non-200 status" + ErrDecodeMDMSResponse = "Failed to decode MDMS response" +) + +// Validation-related error messages +const ( + ErrInvalidUsername = "username must be at least 3 characters long" + ErrInvalidEmail = "invalid email format" + ErrInvalidFirstName = "first name must be at least 2 characters long" + ErrInvalidPassword = "password must be at least 4 characters long" + ErrInvalidPhoneNumber = "phone number must be exactly 10 digits long" + ErrInvalidFullName = "full name must be at least 2 characters long" + ErrInvalidAdhaarNumber = "adhaar number must be a positive number with exactly 12 digits" + ErrInvalidGender = "gender is required" + ErrInvalidRelationship = "relationship to property is required" + ErrInvalidOwnershipShare = "ownership share must be between 0 and 100" + ErrUnsupportedRole = "supported roles are CITIZEN, AGENT, SERVICE_MANAGER, COMMISSIONER" + ErrDuplicateAdhaarNumber = "aadhaar number already exists" + ErrDuplicatePhoneNumber = "phone number already exists" + ErrCheckAdhaarNumberFailed = "failed to check Aadhaar number" + ErrCheckPhoneNumberFailed = "failed to check phone number" + ErrInvalidDateRange = "end_date must be greater than or equal to start_date" + ErrStartDateInPast = "start_date cannot be in the past" + ErrPasswordRequiredForNonCitizens = "password is required for non-CITIZEN roles" +) + +// HTTP error messages for API responses +const ( + // Validation Errors + ErrMsgInvalidRequestPayload = "Invalid request payload" + ErrMsgInvalidRequestBody = "Invalid request body" + ErrMsgInvalidRole = "Invalid role" + ErrMsgInvalidRoleDetail = "Role must be one of: AGENT, CITIZEN, COMMISSIONER, SERVICE_MANAGER" + ErrMsgInvalidIsActive = "Invalid isActive parameter" + ErrMsgInvalidIsActiveDetail = "isActive must be true or false" + ErrMsgInvalidPhoneNumber = "Invalid phone number" + ErrMsgInvalidPhoneDetail = "Phone number must be exactly 10 digits" + ErrMsgMissingUserID = "User ID is required" + ErrMsgMissingUserIDDetail = "Missing user identifier" + + // Operation Errors + ErrMsgFailedToCreateUser = "Failed to create user" + ErrMsgFailedToGetUser = "Failed to get user" + ErrMsgFailedToGetUsers = "Failed to get users" + ErrMsgFailedToUpdateUser = "Failed to update user" + ErrMsgFailedToDeleteUser = "Failed to delete user" + ErrMsgUserNotFound = "User not found" + ErrMsgNoUsersFound = "No users found" +) + +// Success messages for API responses +const ( + SuccessMsgServiceHealthy = "Service is healthy" + SuccessMsgUserCreated = "User created successfully" + SuccessMsgUserRetrieved = "User retrieved successfully" + SuccessMsgUsersRetrieved = "Users retrieved successfully" + SuccessMsgUserUpdated = "User updated successfully" + SuccessMsgUserDeleted = "User deleted successfully" +) + +// Service information constants +const ( + ServiceName = "property-tax-onboarding" + ServiceVersion = "1.0.0" + ServiceStatus = "healthy" +) + +// Pagination-related constants +const ( + DefaultPageLimit = 10 + MaxPageLimit = 100 + DefaultOffset = 0 + PhoneNumberLen = 10 +) diff --git a/backend/onboarding/internal/constants/logs_constants.go b/backend/onboarding/internal/constants/logs_constants.go new file mode 100644 index 0000000..0985cec --- /dev/null +++ b/backend/onboarding/internal/constants/logs_constants.go @@ -0,0 +1,126 @@ +// Package constants defines log message constants for the onboarding service. +// This file centralizes all log messages for maintainability and consistency. +package constants + +// Log messages for UserService operations +const ( + LogCreateUserStart = "Creating new user" + LogCreateUserKeycloak = "Creating user in Keycloak" + LogAssignRoleToUser = "Assigning role to user in Keycloak" + LogBeginTransaction = "Starting database transaction" + LogSaveUserToDatabase = "Saving user to database" + LogSaveUserProfile = "Saving user profile to database" + LogCommitTransaction = "Committing database transaction" + LogRollbackKeycloakUser = "Rolling back Keycloak user creation" + LogZoneValidationStart = "Validating zone and wards" + LogInsertZoneMappings = "Inserting zone mappings" + LogFetchZoneMappings = "Fetching zone mappings for response" + LogFetchAddress = "Fetching address for user profile" + LogUserCreatedSuccessfully = "User created successfully" + LogUserDeletedSuccessfully = "User deleted successfully" + LogUpdateUserStart = "Updating user" + LogUpdateUserSuccess = "User updated successfully" + LogGetUsersWithFiltersStart = "Getting users with filters" + LogUsersRetrieved = "Users retrieved successfully" + LogZoneValidationSuccess = "Zone and wards validated successfully" + LogGetUsersByRoleSuccess = "Successfully retrieved users by role" + LogGetUsersByRoleStart = "Getting users by role" + + // New log messages for GetUser + LogRetrieveUserStart = "Retrieving user" + LogRetrieveUserByUsernameFailed = "Failed to retrieve user by username" + LogRetrieveUserProfileFailed = "Failed to retrieve user profile" + LogRetrieveUserRolesFailed = "Failed to retrieve user roles" + LogFetchZoneMappingsFailed = "Failed to fetch zone mappings" + LogRetrieveUserSuccess = "Successfully retrieved user" +) + +// Log messages for UserRepository operations +// Log messages for Aadhaar and phone checks +const ( + LogRetrieveUserProfileStart = "Starting retrieval of user profile" + LogRetrieveUserProfileSuccess = "Successfully retrieved user profile" + LogDeleteUserStart = "Starting deletion of user and related data" + LogNoUserProfileFound = "No user profile found, skipping profile and address deletion" + LogDeleteAddressStart = "Starting deletion of address" + LogDeleteAddressSuccess = "Successfully deleted address" + LogNoAddressAssociated = "No address associated with user profile, skipping address deletion" + LogDeleteUserProfileSuccess = "Successfully deleted user profile" + LogDeleteUserSuccess = "Successfully deleted user" + LogDeleteUserAndDataSuccess = "Successfully deleted user and all related data" + LogRetrieveAddressStart = "Starting retrieval of address" + LogRetrieveAddressSuccess = "Successfully retrieved address" + LogCountUsersStart = "Starting count of users" + LogCountUsersSuccess = "Successfully counted users" + LogUpdateUserStartt = "Starting update of user" + LogUpdateUserProfileStart = "Starting update of user profile" + LogCreateNewAddressStart = "Starting creation of new address" + LogCreateNewAddressSuccess = "Successfully created new address" + LogUpdateExistingAddressStart = "Starting update of existing address" + LogUpdateExistingAddressSuccess = "Successfully updated existing address" + LogUpdateUserProfileSuccess = "Successfully updated user profile" + LogGetUsersWithFiltersStartRetrive = "Starting retrieval of users with filters" + LogGetUsersWithFiltersSuccess = "Successfully retrieved users with filters" + LogDeleteZoneMappingSuccess = "Successfully deleted zone mappings" + LogUserAlreadyDeleted = "User is already deleted" + LogSoftDeleteUserStart = "Starting soft delete of user" + ErrSoftDeleteUserFailed = "Failed to soft delete user" + LogSoftDeleteUserSuccess = "Successfully soft deleted user" +) + +// Log messages for transaction operations +const ( + LogCheckAdhaarExistsStart = "Starting check for Aadhaar existence" + LogCheckAdhaarExistsSuccess = "Successfully checked Aadhaar existence" + LogCheckPhoneExistsStart = "Starting check for phone number existence" + LogCheckPhoneExistsSuccess = "Successfully checked phone number existence" +) + +const ( + LogCreateUserInTransaction = "Creating user in transaction" + LogUserCreatedInTransaction = "User created successfully in transaction" + LogCreateUserProfileInTransaction = "Creating user profile in transaction" + LogUserProfileCreatedInTransaction = "User profile created successfully in transaction" + LogTransactionCommitted = "Transaction committed successfully" + LogRollbackTransaction = "Rolling back transaction" + LogTransactionRolledBack = "Transaction rolled back successfully" +) + +// Log messages for API requests received +const ( + LogHealthCheckRequested = "health check requested" + LogCreateUserRequest = "create user request received" + LogGetUserRequest = "get user request received" + LogGetUsersByRoleRequest = "get users by role request received" + LogGetAllUsersRequest = "get all users request received" + LogUpdateUserRequest = "update user request received" + LogDeleteUserRequest = "delete user request received" +) + +// Log messages for successful API operations +const ( + LogUserCreatedSuccess = "user created successfully" + LogUserRetrievedSuccess = "user retrieved successfully" + LogUsersRetrievedSuccess = "users retrieved successfully" + LogUserUpdatedSuccess = "user updated successfully" + LogUserDeletedSuccess = "user deleted successfully" +) + +// Log messages for API errors +const ( + LogInvalidRequestPayload = "invalid request payload" + LogInvalidRequestBody = "invalid request body" + LogFailedToCreateUser = "failed to create user" + LogFailedToGetUser = "failed to get user" + LogFailedToGetUsersByRole = "failed to get users by role" + LogFailedToGetUsersFilters = "failed to get users with filters" + LogFailedToUpdateUser = "failed to update user" + LogFailedToDeleteUser = "failed to delete user" +) + +// Log messages for debug and info events +const ( + LogRawRoleParameter = "raw role parameter from URL" + LogUpdateRequestDetails = "update request details" + LogParsedFilters = "parsed filters" +) diff --git a/backend/onboarding/internal/database/database.go b/backend/onboarding/internal/database/database.go new file mode 100644 index 0000000..049dcc9 --- /dev/null +++ b/backend/onboarding/internal/database/database.go @@ -0,0 +1,35 @@ +// Package database provides database connection utilities for the Property Tax Onboarding Service. +// It initializes and returns a GORM database instance configured for PostgreSQL. +package database + +import ( + "fmt" + "property-tax-onboarding/internal/config" + "property-tax-onboarding/pkg/logger" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +// Connect establishes a connection to the PostgreSQL database using GORM. +// It builds the DSN from the provided config, sets the schema, and returns a *gorm.DB instance. +// Logs fatal and exits if the connection fails. +func Connect(cfg *config.Config) *gorm.DB { + dsn := fmt.Sprintf( + "host=%s user=%s password=%s dbname=%s port=%s sslmode=disable client_encoding=UTF8 search_path=%s", + cfg.DBHost, cfg.DBUser, cfg.DBPassword, cfg.DBName, cfg.DBPort, cfg.DBSchema, + ) + + // When initializing GORM, you can specify the schema + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "DIGIT3.", // Prefix all table names with the schema + }, + }) + if err != nil { + logger.Fatal("Failed to connect to database:", err) + } + + logger.Info("Database connected and migrated successfully") + return db +} diff --git a/backend/onboarding/internal/errors/errors.go b/backend/onboarding/internal/errors/errors.go new file mode 100644 index 0000000..53364d7 --- /dev/null +++ b/backend/onboarding/internal/errors/errors.go @@ -0,0 +1,40 @@ +package errors + +import "fmt" + +// ServiceError represents a generic error for all layers of the application. +type ServiceError struct { + Message string // The error message. +} + +// Error implements the error interface. +func (e *ServiceError) Error() string { + return e.Message +} + +// NewServiceError creates a new ServiceError. +func NewServiceError(message string) *ServiceError { + return &ServiceError{ + Message: message, + } +} + +// NewUserServiceError creates a new error specific to the UserService. +func NewUserServiceError(message string) *ServiceError { + return NewServiceError(fmt.Sprintf("UserService: %s", message)) +} + +// NewKeycloakServiceError creates a new error specific to the KeycloakService. +func NewKeycloakServiceError(message string) *ServiceError { + return NewServiceError(fmt.Sprintf("KeycloakService: %s", message)) +} + +// NewRepositoryError creates a new error specific to the Repository layer. +func NewRepositoryError(message string) *ServiceError { + return NewServiceError(fmt.Sprintf("Repository: %s", message)) +} + +// NewUtilityError creates a new error specific to the Utility layer. +func NewUtilityError(message string) *ServiceError { + return NewServiceError(fmt.Sprintf("Utility: %s", message)) +} diff --git a/backend/onboarding/internal/handlers/user_handler.go b/backend/onboarding/internal/handlers/user_handler.go new file mode 100644 index 0000000..d4e4cea --- /dev/null +++ b/backend/onboarding/internal/handlers/user_handler.go @@ -0,0 +1,404 @@ +// Package handlers contains HTTP handler functions for user-related API endpoints. +// It acts as the entry point for request processing, validation, and response formatting. +package handlers + +import ( + "fmt" + "net/http" + "property-tax-onboarding/internal/constants" + "property-tax-onboarding/internal/models" + "property-tax-onboarding/internal/services" + "property-tax-onboarding/pkg/logger" + "property-tax-onboarding/pkg/response" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +// UserHandler handles HTTP requests for user operations. +// It delegates business logic to the UserService. +type UserHandler struct { + userService *services.UserService +} + +// NewUserHandler creates a new instance of UserHandler with the provided UserService. +// Parameters: +// +// -userService: An instance of UserService to handle business logic related to users. +// +// Returns: +// +// -A pointer to the newly created UserHandler instance. +func NewUserHandler(userService *services.UserService) *UserHandler { + return &UserHandler{ + userService: userService, + } +} + +// HealthCheck handles health check requests +// It responds with the service status and version information. +func (uh *UserHandler) HealthCheck(c *gin.Context) { + logger.Info(constants.LogHealthCheckRequested) + + healthResponse := gin.H{ + "status": constants.ServiceStatus, + "service": constants.ServiceName, + "version": constants.ServiceVersion, + } + + response.Success(c, http.StatusOK, constants.SuccessMsgServiceHealthy, healthResponse) +} + +// CreateUser handles unified user creation requests for all user roles. +// +// This endpoint creates a new user in both Keycloak (for authentication) and the local database +// (for profile storage). It supports all user roles: AGENT, CITIZEN, COMMISSIONER, and SERVICE_MANAGER. +// The request is validated using Gin binding tags before processing. +// +// Parameters: +// - c: Gin context containing the HTTP request with JSON payload matching CreateUserRequest. +// +// Request Body: +// - CreateUserRequest JSON payload with username, email, password, role, profile, and optional zone/ward data. +// +// Returns: +// - HTTP 201 (Created) with UserResponse on success. +// - HTTP 400 (Bad Request) if validation fails or user creation fails. + +func (uh *UserHandler) CreateUser(c *gin.Context) { + logger.Info(constants.LogCreateUserRequest) + + var req models.CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.Error(constants.LogInvalidRequestPayload, "error", err) + response.Error(c, http.StatusBadRequest, constants.ErrMsgInvalidRequestPayload, err.Error()) + return + } + + ctx := c.Request.Context() + userResponse, err := uh.userService.CreateUser(ctx, &req) + if err != nil { + logger.Error(constants.LogFailedToCreateUser, "error", err, "role", req.Role) + response.Error(c, http.StatusBadRequest, constants.ErrMsgFailedToCreateUser, err.Error()) + return + } + + logger.Info(constants.LogUserCreatedSuccess, "username", req.Username, "role", req.Role) + response.Success(c, http.StatusCreated, constants.SuccessMsgUserCreated, userResponse) +} + +// GetUser handles unified user retrieval requests by Keycloak user ID or username. +// +// This endpoint fetches user details from the database using either the Keycloak user ID +// (UUID format) or the username. It returns the complete user profile including zone/ward +// mappings and address information. +// +// Parameters: +// - c: Gin context with URL parameter "id" (Keycloak user ID or username). +// +// URL Parameter: +// - id: User identifier (Keycloak UUID or username). +// +// Returns: +// - HTTP 200 (OK) with UserResponse on success. +// - HTTP 404 (Not Found) if user does not exist. +// - HTTP 400 (Bad Request) if identifier is missing. +// - HTTP 500 (Internal Server Error) for other failures. + +func (uh *UserHandler) GetUser(c *gin.Context) { + identifier := c.Param("id") + logger.Info(constants.LogGetUserRequest, "identifier", identifier) + + if identifier == "" { + response.Error(c, http.StatusBadRequest, constants.ErrMsgMissingUserID, constants.ErrMsgMissingUserIDDetail) + return + } + userResponse, err := uh.userService.GetUser(c.Request.Context(), identifier) + if err != nil { + logger.Error(constants.LogFailedToGetUser, "error", err, "identifier", identifier) + response.Error(c, http.StatusInternalServerError, "Failed to get user", err.Error()) + return + } + logger.Info(constants.LogUserRetrievedSuccess, "identifier", identifier) + response.Success(c, http.StatusOK, constants.SuccessMsgUserRetrieved, userResponse) +} + +// GetUsersByRole handles paginated user retrieval filtered by role. +// +// This endpoint fetches users matching a specific role with optional pagination. +// Supported roles: AGENT, CITIZEN, COMMISSIONER, SERVICE_MANAGER (case-insensitive). +// +// Parameters: +// - c: Gin context with URL parameter "role" and optional query parameters "limit" and "offset". +// +// URL Parameter: +// - role: User role (AGENT, CITIZEN, COMMISSIONER, or SERVICE_MANAGER). +// +// Query Parameters: +// - limit: Number of results per page (default: 10, max: 100). +// - offset: Number of results to skip (default: 0). +// +// Returns: +// - HTTP 200 (OK) with array of UserResponse objects. +// - HTTP 400 (Bad Request) if role is invalid. +// - HTTP 500 (Internal Server Error) for database errors. + +func (uh *UserHandler) GetUsersByRole(c *gin.Context) { + roleStr := c.Param("role") + logger.Info("Raw role parameter from URL", "roleStr", roleStr) + + role := models.UserRole(strings.ToUpper(roleStr)) + if !isValidRole(role) { + response.Error(c, http.StatusBadRequest, constants.ErrMsgInvalidRole, constants.ErrMsgInvalidRoleDetail) + return + } + + limit, offset := parsePagination(c) + + users, err := uh.userService.GetUsersByRole(c.Request.Context(), role, limit, offset) + if err != nil { + logger.Error(constants.LogFailedToGetUsersByRole, "error", err, "role", role) + response.Error(c, http.StatusInternalServerError, constants.ErrMsgFailedToGetUsers, err.Error()) + return + } + + logger.Info(constants.LogUsersRetrievedSuccess, "role", role, "count", len(users)) + response.Success(c, http.StatusOK, constants.SuccessMsgUsersRetrieved, users) +} + +// DeleteUser handles user deletion requests by Keycloak user ID. +// +// This endpoint performs a soft delete by marking the user as inactive in both Keycloak +// and the local database. The user record is retained for audit purposes but cannot log in. +// +// Parameters: +// - c: Gin context with URL parameter "id" (Keycloak user ID). +// +// URL Parameter: +// - id: Keycloak user ID. +// +// Returns: +// - HTTP 200 (OK) on successful deletion. +// - HTTP 404 (Not Found) if user does not exist. +// - HTTP 500 (Internal Server Error) for other failures. +func (uh *UserHandler) DeleteUser(c *gin.Context) { + keycloakUserID := c.Param("id") + logger.Info(constants.LogDeleteUserRequest, "keycloakUserID", keycloakUserID) + + if err := uh.userService.DeleteUserByKeycloakID(keycloakUserID); err != nil { + logger.Error(constants.LogFailedToDeleteUser, "error", err, "keycloakUserID", keycloakUserID) + + if err.Error() == fmt.Sprintf("user not found with keycloak_user_id: %s", keycloakUserID) { + response.Error(c, http.StatusNotFound, constants.ErrMsgUserNotFound, err.Error()) + return + } + response.Error(c, http.StatusInternalServerError, constants.ErrMsgFailedToDeleteUser, err.Error()) + return + } + logger.Info("User deleted successfully", "keycloakUserID", keycloakUserID) + response.Success(c, http.StatusOK, "User deleted successfully", nil) +} + +// UpdateUser handles user profile update requests by Keycloak user ID. +// This endpoint updates user details in both Keycloak (email, role) and the local database +// (profile fields, address, zone/ward mappings). All fields in UpdateUserRequest are optional +// except email and role. +// Parameters: +// - c: Gin context with URL parameter "id" and JSON body matching UpdateUserRequest. +// +// URL Parameter: +// - id: Keycloak user ID. +// +// Request Body: +// - UpdateUserRequest JSON payload with fields to update. +// +// Returns: +// - HTTP 200 (OK) with updated UserResponse on success. +// - HTTP 400 (Bad Request) if validation fails. +// - HTTP 404 (Not Found) if user does not exist. +func (uh *UserHandler) UpdateUser(c *gin.Context) { + keycloakUserID := c.Param("id") + logger.Info(constants.LogUpdateUserRequest, "keycloakUserID", keycloakUserID) + + var updateReq models.UpdateUserRequest + if err := c.ShouldBindJSON(&updateReq); err != nil { + logger.Error(constants.LogInvalidRequestBody, "error", err) + response.Error(c, http.StatusBadRequest, constants.ErrMsgInvalidRequestBody, err.Error()) + return + } + + logger.Info(constants.LogUpdateRequestDetails, "email", updateReq.Email, "role", updateReq.Role, "preferredLanguage", updateReq.PreferredLanguage) + + updatedUser, err := uh.userService.UpdateUserComplete(keycloakUserID, &updateReq) + if err != nil { + logger.Error(constants.LogFailedToUpdateUser, "error", err, "keycloakUserID", keycloakUserID) + if err.Error() == "user not found" { + response.Error(c, http.StatusNotFound, constants.ErrMsgUserNotFound, err.Error()) + return + } + + response.Error(c, http.StatusBadRequest, constants.ErrMsgFailedToUpdateUser, err.Error()) + return + } + + logger.Info(constants.LogUserUpdatedSuccess, "keycloakUserID", keycloakUserID) + response.Success(c, http.StatusOK, constants.SuccessMsgUserUpdated, updatedUser) +} + +// GetAllUsers handles paginated user retrieval with optional filters. +// +// This endpoint supports filtering users by role, active status, username, email, ward, +// and phone number. Results are paginated with configurable limit and offset. +// +// Parameters: +// - c: Gin context with optional query parameters for filtering and pagination. +// +// Query Parameters (all optional): +// - role: Filter by user role (AGENT, CITIZEN, COMMISSIONER, SERVICE_MANAGER). +// - isActive: Filter by active status (true/false). +// - username: Partial match on username (case-insensitive). +// - email: Partial match on email (case-insensitive). +// - ward: Filter by assigned ward. +// - phoneNumber: Exact match on 10-digit phone number. +// - limit: Results per page (default: 10, max: 100). +// - offset: Number of results to skip (default: 0). +func (uh *UserHandler) GetAllUsers(c *gin.Context) { + logger.Info("Get all users request received") + + var filters models.UserFilters + + // Role filter + if roleStr := c.Query("role"); roleStr != "" { + roleLisdt := strings.Split(roleStr, ",") + var roles []models.UserRole + for _, r := range roleLisdt { + role := models.UserRole(strings.ToUpper(strings.TrimSpace(r))) + if !isValidRole(role) { + response.Error(c, http.StatusBadRequest, constants.ErrMsgInvalidRole, constants.ErrMsgInvalidRoleDetail) + return + } + roles = append(roles, role) + } + if len(roles) > 0 { + filters.Role = roles + } + } + + // IsActive filter + if isActiveStr := c.Query("is_active"); isActiveStr != "" { + if isActive, err := strconv.ParseBool(isActiveStr); err == nil { + filters.IsActive = &isActive + } else { + response.Error(c, http.StatusBadRequest, constants.ErrMsgInvalidIsActive, constants.ErrMsgInvalidIsActiveDetail) + return + } + } + + // Username filter (partial match) + if username := c.Query("username"); username != "" { + filters.Username = &username + } + + // Email filter (partial match) + if email := c.Query("email"); email != "" { + filters.Email = &email + } + + // Ward filter + if ward := c.Query("ward"); ward != "" { + wardlist := strings.Split(ward, ",") + filters.Ward = &wardlist + } + + if phoneNumber := c.Query("phoneNumber"); phoneNumber != "" { + // Validate phone number format (10 digits) + if len(strings.TrimSpace(phoneNumber)) != 10 { + response.Error(c, http.StatusBadRequest, constants.ErrMsgInvalidPhoneNumber, constants.ErrMsgInvalidPhoneDetail) + return + } + filters.PhoneNumber = &phoneNumber + } + + limit, offset := parsePagination(c) + + logger.Info(constants.LogParsedFilters, "role", filters.Role, "isActive", filters.IsActive, "username", filters.Username, "email", filters.Email, "ward", filters.Ward, "limit", limit, "offset", offset) + + usersResponse, err := uh.userService.GetAllUsersWithFilters(c.Request.Context(), filters, limit, offset) + if err != nil { + logger.Error(constants.LogFailedToGetUsersFilters, "error", err) + + // Check if error is "no users found" + if strings.Contains(err.Error(), "no users found") { + response.Error(c, http.StatusNotFound, "No users found", err.Error()) + return + } + + // Other errors + response.Error(c, http.StatusInternalServerError, "Failed to get users", err.Error()) + return + } + + logger.Info("Users retrieved successfully", "totalCount", usersResponse.TotalCount, "returnedCount", len(usersResponse.Users)) + response.Success(c, http.StatusOK, "Users retrieved successfully", usersResponse) +} + +// parsePagination extracts pagination parameters (limit, offset) from the request query. +// Applies defaults and enforces max limits to prevent overload. +func parsePagination(c *gin.Context) (int, int) { + limitStr := c.DefaultQuery("limit", strconv.Itoa(constants.DefaultPageLimit)) + offsetStr := c.DefaultQuery("offset", strconv.Itoa(constants.DefaultOffset)) + + limit, err := strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + limit = constants.DefaultPageLimit + } + if limit > constants.MaxPageLimit { + limit = constants.MaxPageLimit + } + + offset, err := strconv.Atoi(offsetStr) + if err != nil || offset < 0 { + offset = constants.DefaultOffset + } + + return limit, offset +} + +// isValidRole checks if the provided role is a valid user role for this service. +// Used to validate input and prevent invalid role queries. +func isValidRole(role models.UserRole) bool { + switch role { + case models.RoleAgent, models.RoleCitizen, models.RoleCommissioner, models.RoleServiceManager, models.RoleAdmin: + return true + default: + return false + } +} + +// GetUserCounts handles requests to retrieve counts of users. +// +// This endpoint returns counts for: +// - Total users (excluding deleted) +// - Active users (is_active = true) +// - Field agents (role = AGENT) +// +// Parameters: +// - c: Gin context for the HTTP request. +// +// Returns: +// - HTTP 200 (OK) with count statistics. +// - HTTP 500 (Internal Server Error) if the operation fails. +func (uh *UserHandler) GetUserCounts(c *gin.Context) { + logger.Info("Get user counts request received") + + counts, err := uh.userService.GetUserCounts(c.Request.Context()) + if err != nil { + logger.Error("Failed to get user counts", "error", err) + response.Error(c, http.StatusInternalServerError, "Failed to get user counts", err.Error()) + return + } + + logger.Info("User counts retrieved successfully", "counts", counts) + response.Success(c, http.StatusOK, "User counts retrieved successfully", counts) +} diff --git a/backend/onboarding/internal/middleware/cors.go b/backend/onboarding/internal/middleware/cors.go new file mode 100644 index 0000000..66c645f --- /dev/null +++ b/backend/onboarding/internal/middleware/cors.go @@ -0,0 +1,91 @@ +// Package middleware provides custom Gin middleware for the Property Tax Onboarding Service. +// This file implements a configurable CORS middleware for cross-origin HTTP requests. +package middleware + +import ( + "strconv" + "github.com/gin-gonic/gin" +) + +// CORSConfig holds configuration options for CORS (Cross-Origin Resource Sharing). +// Allows fine-grained control over allowed origins, methods, headers, credentials, and cache duration. +type CORSConfig struct { + AllowedOrigins []string + AllowedMethods []string + AllowedHeaders []string + AllowCredentials bool + MaxAge int +} + +// CORSWithConfig returns a Gin middleware handler that applies CORS headers based on the provided config. +// Supports dynamic origin, method, and header whitelisting, credentials, and preflight request handling. +func CORSWithConfig(config CORSConfig) gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.Request.Header.Get("Origin") + + // Check if the request's Origin is allowed by the config + allowed := false + for _, allowedOrigin := range config.AllowedOrigins { + if allowedOrigin == "*" || allowedOrigin == origin { + allowed = true + break + } + } + + if allowed { + // Allow all origins or echo back the request origin + if len(config.AllowedOrigins) == 1 && config.AllowedOrigins[0] == "*" { + c.Header("Access-Control-Allow-Origin", "*") + } else { + c.Header("Access-Control-Allow-Origin", origin) + } + } + + // Set allowed HTTP methods for CORS + methods := "GET, POST, PUT, DELETE, PATCH, OPTIONS" + if len(config.AllowedMethods) > 0 { + methods = "" + for i, method := range config.AllowedMethods { + if i > 0 { + methods += ", " + } + methods += method + } + } + c.Header("Access-Control-Allow-Methods", methods) + + // Set allowed HTTP headers for CORS + headers := "Origin, Content-Type, Accept, Authorization, X-Requested-With, X-Tenant-ID, X-User-ID" + if len(config.AllowedHeaders) > 0 { + headers = "" + for i, header := range config.AllowedHeaders { + if i > 0 { + headers += ", " + } + headers += header + } + } + c.Header("Access-Control-Allow-Headers", headers) + + // Set whether credentials (cookies, auth) are allowed + if config.AllowCredentials { + c.Header("Access-Control-Allow-Credentials", "true") + } + + // Set how long the results of a preflight request can be cached + maxAge := 86400 // 24 hours default + if config.MaxAge > 0 { + maxAge = config.MaxAge + } + c.Header("Access-Control-Max-Age", strconv.Itoa(maxAge)) + + // Handle preflight OPTIONS request by returning 204 No Content + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + // Continue to next middleware/handler + c.Next() + } +} \ No newline at end of file diff --git a/backend/onboarding/internal/models/database_models.go b/backend/onboarding/internal/models/database_models.go new file mode 100644 index 0000000..7afdede --- /dev/null +++ b/backend/onboarding/internal/models/database_models.go @@ -0,0 +1,96 @@ +// Package models defines database models and ORM mappings for the onboarding service. +// This file contains GORM models for users, profiles, addresses, and zone mappings. +package models + +import ( + "time" + "github.com/lib/pq" +) + +// UserRole represents the possible user roles in the system. +// Used for role-based access and filtering. +type UserRole string + +const ( + RoleAgent UserRole = "AGENT" + RoleCitizen UserRole = "CITIZEN" + RoleCommissioner UserRole = "COMMISSIONER" + RoleServiceManager UserRole = "SERVICE_MANAGER" + RoleAdmin UserRole = "ADMIN" +) + +// User represents the unified database model for all users. +// Maps to the users table and includes profile and zone mapping relations. +type User struct { + KeycloakUserID string `gorm:"primaryKey;column:keycloak_user_id;type:varchar(255)" json:"keycloak_user_id"` // Primary key + Username string `gorm:"unique;column:username;type:varchar(255)" json:"username"` // Unique username + Email string `gorm:"unique;column:email;type:varchar(255)" json:"email"` // Unique email + Role UserRole `gorm:"column:role;type:varchar(50)" json:"role"` // User role (e.g., AGENT, CITIZEN, etc.) + IsActive bool `gorm:"column:is_active;type:boolean" json:"is_active"` // Indicates if the user is active + PreferredLanguage *string `gorm:"column:preferred_language;type:varchar(50)" json:"preferredLanguage,omitempty"` // Preferred language (nullable) + CreatedAt time.Time `gorm:"column:created_at;type:timestamp;autoCreateTime" json:"created_at"` // Timestamp when the record was created + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;autoUpdateTime" json:"updated_at"` // Timestamp when the record was last updated + CreatedBy *string `gorm:"column:created_by;type:varchar(255)" json:"createdBy,omitempty"` // User who created the record (nullable) + UpdatedBy *string `gorm:"column:updated_by;type:varchar(255)" json:"updatedBy,omitempty"` // User who last updated the record (nullable)" + Deleted bool `gorm:"column:deleted;type:boolean;default:false" json:"deleted"` // Soft delete flag + StartDate *time.Time `gorm:"column:start_date;type:date"` + EndDate *time.Time `gorm:"column:end_date;type:date"` + Profile *UserProfile `gorm:"foreignKey:user_profile_id;references:keycloak_user_id" json:"profile,omitempty"` + ZoneMappings []ZoneMapping `gorm:"foreignKey:UserID;references:KeycloakUserID"` +} + +// UserProfile represents the user profile table in the database. +// Stores personal, contact, and property relationship details. +type UserProfile struct { + ID string `gorm:"primaryKey;column:user_profile_id" json:"user_profile_id"` // Use string instead of UUID + FirstName string `gorm:"column:first_name" json:"first_name"` + LastName string `gorm:"column:last_name" json:"last_name"` + FullName string `gorm:"column:full_name" json:"full_name"` + PhoneNumber string `gorm:"column:phone_number" json:"phone_number"` + AdhaarNo *int64 `gorm:"column:adhaar_no" json:"adhaar_no"` + Gender string `gorm:"column:gender" json:"gender"` + Guardian *string `gorm:"column:guardian" json:"guardian"` + GuardianType *string `gorm:"column:guardian_type" json:"guardian_type"` + DateOfBirth *time.Time `gorm:"column:date_of_birth" json:"date_of_birth"` + Department *string `gorm:"column:department" json:"department"` + Designation *string `gorm:"column:designation" json:"designation"` + WorkLocation *string `gorm:"column:work_location" json:"work_location"` + ProfilePicture *string `gorm:"column:profile_picture" json:"profile_picture"` + RelationshipToProperty string `gorm:"column:relationship_to_property" json:"relationship_to_property"` + OwnershipShare float64 `gorm:"column:ownership_share" json:"ownership_share"` + IsPrimaryOwner bool `gorm:"column:is_primary_owner" json:"is_primary_owner"` + IsVerified bool `gorm:"column:is_verified" json:"is_verified"` + AddressID *string `gorm:"column:address_id" json:"address_id"` + Address *AddressDB `gorm:"foreignKey:AddressID;references:ID" json:"address,omitempty"` +} + +// AddressDB represents the address table in the database. +// Used as a foreign key in user profiles. +type AddressDB struct { + ID string `gorm:"primaryKey;column:id;type:uuid;default:gen_random_uuid()" json:"id"` + AddressLine1 string `gorm:"column:address_line1" json:"addressLine1"` + AddressLine2 *string `gorm:"column:address_line2" json:"addressLine2"` + City string `gorm:"column:city" json:"city"` + State string `gorm:"column:state" json:"state"` + PinCode string `gorm:"column:pin_code" json:"pinCode"` +} + +// TableName overrides the default table name for AddressDB. +func (AddressDB) TableName() string { + return "DIGIT3.address" +} + +// ZoneMapping stores mapping of user to zones and wards. +// Used for zone/ward assignment and filtering. +type ZoneMapping struct { + UserID string `gorm:"column:user_id;type:varchar(255);not null" json:"user_id"` + Zone string `gorm:"column:zone;type:varchar(50)" json:"zone"` + Wards pq.StringArray `gorm:"column:ward;type:text[]" json:"wards"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` +} + +// TableName overrides the default table name for ZoneMapping. +func (ZoneMapping) TableName() string { + return "DIGIT3.zone_mapping" +} diff --git a/backend/onboarding/internal/models/user.go b/backend/onboarding/internal/models/user.go new file mode 100644 index 0000000..9cff012 --- /dev/null +++ b/backend/onboarding/internal/models/user.go @@ -0,0 +1,258 @@ +// Package models defines data structures and DTOs for API requests, responses, and database mapping. +// This file contains user-related models for the Property Tax Onboarding Service. +package models + +import ( + "fmt" + "time" +) + +// CreateUserRequest represents the request payload for creating a new user (citizen, agent, etc.). +// Used in POST /api/v1/users. Includes profile and ward info. +type CreateUserRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Role UserRole `json:"role"` + ZoneData []ZoneData `json:"zonedata"` + PreferredLanguage *string `json:"preferredLanguage,omitempty"` + CreatedBy *string `json:"createdBy,omitempty"` + UpdatedBy *string `json:"updatedBy,omitempty"` + Deleted bool `json:"deleted,omitempty"` + StartDate *time.Time `json:"startDate,omitempty"` + EndDate *time.Time `json:"endDate,omitempty"` + Profile UserProfileRequest `json:"profile"` +} + +type ZoneData struct { + ZoneNumber string `json:"zoneNumber"` + Wards []string `json:"wards"` +} + +// UserResponse represents the unified response payload for any user +type UserResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Role string `json:"role"` + IsActive bool `json:"isActive"` + ZoneData []ZoneData `json:"zoneData"` + PreferredLanguage *string `json:"preferredLanguage,omitempty"` + CreatedDate time.Time `json:"createdDate"` + UpdatedDate time.Time `json:"updatedDate"` + CreatedBy *string `json:"createdBy,omitempty"` + UpdatedBy *string `json:"updatedBy,omitempty"` + StartDate *time.Time `json:"startDate,omitempty"` + EndDate *time.Time `json:"endDate,omitempty"` + Profile UserProfileResponse `json:"profile"` +} + +// UserProfileResponse represents the profile section of a user in API responses. +// Includes personal, contact, and property relationship details. +type UserProfileResponse struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + FullName string `json:"fullName"` + PhoneNumber string `json:"phoneNumber"` + AdhaarNo *int64 `json:"adhaarNo"` + Gender string `json:"gender"` + Guardian *string `json:"guardian,omitempty"` + GuardianType *string `json:"guardianType,omitempty"` + DateOfBirth *time.Time `json:"dateOfBirth,omitempty"` + Address *Address `json:"address,omitempty"` + Department *string `json:"department,omitempty"` + Designation *string `json:"designation,omitempty"` + WorkLocation *string `json:"workLocation,omitempty"` + ProfilePicture *string `json:"profilePicture,omitempty"` + RelationshipToProperty string `json:"relationshipToProperty"` + OwnershipShare float64 `json:"ownershipShare"` + IsPrimaryOwner bool `json:"isPrimaryOwner"` + IsVerified bool `json:"isVerified"` +} + +// UserProfileRequest represents the profile section in user creation/update requests. +// Used for validating and capturing user profile details. +type UserProfileRequest struct { + FirstName string `json:"firstName" binding:"required"` + LastName string `json:"lastName" binding:"required"` + FullName string `json:"fullName" binding:"required"` + PhoneNumber string `json:"phoneNumber" binding:"required"` + AdhaarNo *int64 `json:"adhaarNo"` + Gender string `json:"gender"` + RelationshipToProperty string `json:"relationshipToProperty"` + OwnershipShare float64 `json:"ownershipShare"` + IsPrimaryOwner bool `json:"isPrimaryOwner"` + Department *string `json:"department"` + Designation *string `json:"designation"` +} + +// Address represents a user's address in both requests and responses. +// Used as a nested struct in user profile models. +type Address struct { + AddressLine1 string `json:"addressLine1"` + AddressLine2 *string `json:"addressLine2"` + City string `json:"city"` + State string `json:"state"` + PinCode string `json:"pinCode"` +} + +// KeycloakUser represents a user object for Keycloak integration. +// Used for syncing user data with the Keycloak identity provider. +type KeycloakUser struct { + ID string `json:"id,omitempty"` + Username string `json:"username"` + Email string `json:"email"` + EmailVerified bool `json:"emailVerified"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Enabled bool `json:"enabled"` + Attributes map[string]interface{} `json:"attributes,omitempty"` + Credentials []KeycloakCredential `json:"credentials,omitempty"` +} + +// KeycloakCredential represents a credential object for Keycloak users. +// Used for password and authentication management in Keycloak. +type KeycloakCredential struct { + Type string `json:"type"` + Value string `json:"value"` + Temporary bool `json:"temporary"` +} + +// KeycloakRole represents a role in Keycloak +type KeycloakRole struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// UpdateUserRequest represents the request payload for updating a user +type UpdateUserRequest struct { + Email string `json:"email"` + Role UserRole `json:"role"` + IsActive bool `json:"isActive"` + PreferredLanguage *string `json:"preferredLanguage"` + Profile UpdateUserProfile `json:"profile"` + CreatedBy *string `json:"createdBy,omitempty"` + UpdatedBy *string `json:"updatedBy,omitempty"` + EndDate *time.Time `json:"endDate,omitempty"` +} + +// UpdateUserProfile represents the profile data in update request +type UpdateUserProfile struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + FullName string `json:"fullName"` + PhoneNumber string `json:"phoneNumber"` + AdhaarNo *int64 `json:"adhaarNo"` + Gender string `json:"gender"` + Guardian string `json:"guardian"` + GuardianType string `json:"guardianType"` + DateOfBirth string `json:"dateOfBirth"` + Department string `json:"department"` + Designation string `json:"designation"` + WorkLocation string `json:"workLocation"` + ProfilePicture string `json:"profilePicture"` + RelationshipToProperty string `json:"relationshipToProperty"` + OwnershipShare float64 `json:"ownershipShare"` + IsPrimaryOwner bool `json:"isPrimaryOwner"` + IsVerified bool `json:"isVerified"` + AddressID *string `gorm:"column:address_id" json:"addressId"` + Address *AddressDB `json:"address,omitempty"` +} + +// UserFilters represents filters for getting users with search criteria +type UserFilters struct { + Role []UserRole `json:"role,omitempty"` + IsActive *bool `json:"isActive,omitempty"` + Username *string `json:"username,omitempty"` // Partial match + Email *string `json:"email,omitempty"` // Partial match + Ward *[]string `json:"ward,omitempty"` + Deleted bool `json:"deleted,omitempty"` + PhoneNumber *string `json:"phoneNumber,omitempty"` +} + +// UsersListResponse represents the response for getting multiple users with pagination +type UsersListResponse struct { + Users []*UserResponse `json:"users"` + TotalCount int + Limit int + Offset int +} + +// CreateUserResponse creates a UserResponse from a database User model +func (ur *UserResponse) CreateUserResponse(user *User, userProfile *UserProfile) { + // Basic user fields + ur.ID = user.KeycloakUserID + ur.Username = user.Username + ur.Email = user.Email + ur.Role = string(user.Role) + ur.IsActive = user.IsActive + ur.PreferredLanguage = user.PreferredLanguage + ur.CreatedDate = user.CreatedAt + ur.UpdatedDate = user.UpdatedAt + ur.StartDate = user.StartDate + ur.EndDate = user.EndDate + ur.CreatedBy = user.CreatedBy + ur.UpdatedBy = user.UpdatedBy + + // Always populate profile fields, even if userProfile is nil + if userProfile != nil { + ur.Profile = UserProfileResponse{ + FirstName: userProfile.FirstName, + LastName: userProfile.LastName, + FullName: userProfile.FullName, + PhoneNumber: userProfile.PhoneNumber, + AdhaarNo: userProfile.AdhaarNo, + Gender: userProfile.Gender, + Guardian: userProfile.Guardian, + GuardianType: userProfile.GuardianType, + DateOfBirth: userProfile.DateOfBirth, + Department: userProfile.Department, + Designation: userProfile.Designation, + WorkLocation: userProfile.WorkLocation, + ProfilePicture: userProfile.ProfilePicture, + RelationshipToProperty: userProfile.RelationshipToProperty, + OwnershipShare: userProfile.OwnershipShare, + IsPrimaryOwner: userProfile.IsPrimaryOwner, + IsVerified: userProfile.IsVerified, + Address: &Address{}, + } + } +} + +func CreateUserFromRequest(req *CreateUserRequest, keycloakUserID string) (*User, error) { + if req.Username == "" || req.Email == "" { + return nil, fmt.Errorf("username and email are required") + } + return &User{ + KeycloakUserID: keycloakUserID, + Username: req.Username, + Email: req.Email, + Role: req.Role, + PreferredLanguage: req.PreferredLanguage, + IsActive: true, + StartDate: req.StartDate, + EndDate: req.EndDate, + CreatedBy: req.CreatedBy, + UpdatedBy: req.UpdatedBy, + }, nil +} + +// CreateUserProfileFromRequest creates a UserProfile from UserProfileRequest +func CreateUserProfileFromRequest(req *UserProfileRequest, userID string) *UserProfile { + return &UserProfile{ + ID: userID, // Use the same ID as user (KeycloakUserID) + FirstName: req.FirstName, + LastName: req.LastName, + FullName: req.FullName, + PhoneNumber: req.PhoneNumber, + AdhaarNo: req.AdhaarNo, + Gender: req.Gender, + RelationshipToProperty: req.RelationshipToProperty, + OwnershipShare: req.OwnershipShare, + IsPrimaryOwner: req.IsPrimaryOwner, + Department: req.Department, + Designation: req.Designation, + IsVerified: false, + } +} diff --git a/backend/onboarding/internal/repositories/interfaces.go b/backend/onboarding/internal/repositories/interfaces.go new file mode 100644 index 0000000..114c30e --- /dev/null +++ b/backend/onboarding/internal/repositories/interfaces.go @@ -0,0 +1,37 @@ +package repositories + +import ( + "context" + "property-tax-onboarding/internal/models" +) + +// UserRepository defines the interface for unified user database operations +type UserRepository interface { + GetByKeycloakUserID(ctx context.Context, keycloakUserID string) (*models.User, error) + GetByRole(ctx context.Context, role models.UserRole, limit, offset int) ([]*models.User, error) + Delete(ctx context.Context, keycloakUserID string) error + Count() (int64, error) + GetUserCounts(ctx context.Context) (int64, int64, int64, error) + + // UserProfile operations + GetUserProfileByUserID(ctx context.Context,userID string) (*models.UserProfile, error) + + // Address operations + GetAddressByID(ctx context.Context, addressID string) (*models.AddressDB, error) + + // Update operations + UpdateUser(ctx context.Context, keycloakUserID string, updateReq *models.UpdateUserRequest) error + UpdateUserProfileComplete(ctx context.Context, keycloakUserID string, profile *models.UpdateUserProfile) error + + // Get all users with filters + GetAllUsersWithFilters(ctx context.Context,filters models.UserFilters, limit, offset int) ([]*models.User, int64, error) + + // Transaction management + BeginTransaction(ctx context.Context) (Transaction, error) + UpdateUserIsActive(ctx context.Context, userID string, isActive bool) error + + // New methods for scheduler + GetUsersByStartDate(ctx context.Context, date string) ([]models.User, error) + GetUsersByEndDate(ctx context.Context, date string) ([]models.User, error) + GetUsersWithFutureStartDate(ctx context.Context, currentDate string) ([]models.User, error) +} diff --git a/backend/onboarding/internal/repositories/transaction.go b/backend/onboarding/internal/repositories/transaction.go new file mode 100644 index 0000000..a5626c1 --- /dev/null +++ b/backend/onboarding/internal/repositories/transaction.go @@ -0,0 +1,119 @@ +package repositories + +import ( + "context" + "fmt" + "property-tax-onboarding/internal/constants" + "property-tax-onboarding/internal/models" + "property-tax-onboarding/pkg/logger" + "gorm.io/gorm" +) + +// Transaction interface for repository operations +// +// Description: +// This interface defines the methods required for transactional operations +// in the repository layer, such as creating users and user profiles, and +// managing transaction commits and rollbacks. +type Transaction interface { + Create(user *models.User) error + CreateUserProfile(profile *models.UserProfile) error + Commit() error + Rollback() error +} + +// PostgreSQLTransaction implements Transaction interface +// +// Description: +// This struct provides an implementation of the Transaction interface +// using PostgreSQL and GORM for database operations. +type PostgreSQLTransaction struct { + tx *gorm.DB +} + +// Create inserts a new user within a transaction. +// +// Parameters: +// - user: A pointer to the User object to be created. +// +// Returns: +// - An error if the operation fails. +func (t *PostgreSQLTransaction) Create(user *models.User) error { + logger.Info(constants.LogCreateUserInTransaction, "keycloak_user_id", user.KeycloakUserID) + + if err := t.tx.Create(user).Error; err != nil { + logger.Error(constants.ErrCreateUserInTransaction, "error", err) + return fmt.Errorf("%s: %w", constants.ErrCreateUserInTransaction, err) + } + + logger.Info(constants.LogUserCreatedInTransaction, "keycloak_user_id", user.KeycloakUserID) + return nil +} + +// CreateUserProfile inserts a new user profile within a transaction. +// +// Parameters: +// - profile: A pointer to the UserProfile object to be created. +// +// Returns: +// - An error if the operation fails. +func (t *PostgreSQLTransaction) CreateUserProfile(profile *models.UserProfile) error { + logger.Info(constants.LogCreateUserProfileInTransaction, "profileID", profile.ID) + + if err := t.tx.Create(profile).Error; err != nil { + logger.Error(constants.ErrCreateUserProfileInTransaction, "error", err, "profileID", profile.ID) + return fmt.Errorf("%s: %w", constants.ErrCreateUserProfileInTransaction, err) + } + + logger.Info(constants.LogUserProfileCreatedInTransaction, "profileID", profile.ID) + return nil +} + +// Commit commits the transaction. +// +// Returns: +// - An error if the operation fails. +func (t *PostgreSQLTransaction) Commit() error { + logger.Info(constants.LogCommitTransaction) + + if err := t.tx.Commit().Error; err != nil { + logger.Error(constants.ErrCommitTransaction, "error", err) + return fmt.Errorf("%s: %w", constants.ErrCommitTransaction, err) + } + + logger.Info(constants.LogTransactionCommitted) + return nil +} + +// Rollback rolls back the transaction. +// +// Returns: +// - An error if the operation fails. +func (t *PostgreSQLTransaction) Rollback() error { + logger.Info(constants.LogRollbackTransaction) + + if err := t.tx.Rollback().Error; err != nil { + logger.Error(constants.ErrRollbackTransaction, "error", err) + return fmt.Errorf("%s: %w", constants.ErrRollbackTransaction, err) + } + + logger.Info(constants.LogTransactionRolledBack) + return nil +} + +// BeginTransaction starts a new database transaction. +// +// Returns: +// - A Transaction object for managing the transaction. +// - An error if the transaction could not be started. +func (r *PostgreSQLUserRepository) BeginTransaction(ctx context.Context) (Transaction, error) { + logger.Info(constants.LogBeginTransaction) + + tx := r.dbd.WithContext(ctx).Begin() + if tx.Error != nil { + logger.Error(constants.ErrBeginTransaction, "error", tx.Error) + return nil, fmt.Errorf("%s: %w", constants.ErrBeginTransaction, tx.Error) + } + + return &PostgreSQLTransaction{tx: tx}, nil +} diff --git a/backend/onboarding/internal/repositories/users_repo.go b/backend/onboarding/internal/repositories/users_repo.go new file mode 100644 index 0000000..b66bcb3 --- /dev/null +++ b/backend/onboarding/internal/repositories/users_repo.go @@ -0,0 +1,496 @@ +package repositories + +import ( + "context" + "fmt" + "property-tax-onboarding/internal/constants" + "property-tax-onboarding/internal/errors" + "property-tax-onboarding/internal/models" + "property-tax-onboarding/pkg/logger" + "github.com/google/uuid" + "github.com/lib/pq" + "gorm.io/gorm" +) + +// PostgreSQLUserRepository implements UserRepository interface for unified users table +type PostgreSQLUserRepository struct { + dbd *gorm.DB +} + +// NewPostgreSQLUserRepository creates a new PostgreSQL user repository +func NewPostgreSQLUserRepository(dbd *gorm.DB) UserRepository { + return &PostgreSQLUserRepository{dbd: dbd} +} + +// GetUserProfileByUserID retrieves a user profile by user ID. +// +// Parameters: +// - userID: The unique identifier of the user whose profile is to be retrieved. +// +// Returns: +// - A pointer to the UserProfile object if found. +// - An error if the operation fails or the user profile is not found. +// +// Description: +// This method queries the database to retrieve the user profile associated with the given user ID. +// If the user profile is not found, it returns an error. The method uses centralized error handling +// and logs the operation at various stages for traceability. +func (r *PostgreSQLUserRepository) GetUserProfileByUserID(ctx context.Context, userID string) (*models.UserProfile, error) { + logger.Info(constants.LogRetrieveUserProfileStart, "userID", userID) + + // Create an empty UserProfile object to hold the result + var profile models.UserProfile + + if err := r.dbd.WithContext(ctx).Where("user_profile_id = ?", userID).First(&profile).Error; err != nil { + if err == gorm.ErrRecordNotFound { + logger.Error(constants.ErrUserProfileNotFound, "userID", userID) + return nil, errors.NewRepositoryError(fmt.Sprintf("%s: %s", constants.ErrUserProfileNotFound, userID)) + } + logger.Error(constants.ErrRetrieveUserProfileFailed, "error", err, "userID", userID) + return nil, errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrRetrieveUserProfileFailed, err)) + } + + logger.Info(constants.LogRetrieveUserProfileSuccess, "userID", userID) + return &profile, nil +} + +// GetByKeycloakUserID retrieves a user by Keycloak user ID. +// +// Parameters: +// - keycloakUserID: The unique identifier of the user in Keycloak. +// +// Returns: +// - A pointer to the User object if found. +// - An error if the operation fails or the user is not found. +// +// Description: +// This method queries the database to retrieve the user associated with the given Keycloak user ID. +// If the user is not found, it returns an error. The method uses centralized error handling +// and logs the operation at various stages for traceability. +func (r *PostgreSQLUserRepository) GetByKeycloakUserID(ctx context.Context, keycloakUserID string) (*models.User, error) { + logger.Info(constants.LogRetrieveUserStart, "keycloak_user_id", keycloakUserID) + + // Create an empty User object to hold the result + var user models.User + + // Use GORM to find the user by Keycloak user ID + if err := r.dbd.WithContext(ctx).Where("keycloak_user_id = ?", keycloakUserID).First(&user).Error; err != nil { + if err == gorm.ErrRecordNotFound { + logger.Error(constants.ErrUserNotFound, "keycloak_user_id", keycloakUserID) + return nil, errors.NewRepositoryError(fmt.Sprintf("%s: %s", constants.ErrUserNotFound, keycloakUserID)) + } + logger.Error(constants.ErrRetrieveUserFailed, "error", err, "keycloak_user_id", keycloakUserID) + return nil, errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrRetrieveUserFailed, err)) + } + + logger.Info(constants.LogRetrieveUserSuccess, "keycloak_user_id", keycloakUserID) + return &user, nil +} + +// GetByRole retrieves all users by role with pagination. +// +// Parameters: +// - role: The role of the users to retrieve. +// - limit: The maximum number of users to retrieve. +// - offset: The starting point for pagination. +// +// Returns: +// - A slice of pointers to User objects if found. +// - An error if the operation fails or no users are found. +// +// Description: +// This method queries the database to retrieve users associated with the given role. +// It supports pagination using the limit and offset parameters. If no users are found, +// or if an error occurs during the query, it returns an appropriate error. The method +// uses centralized error handling and logs the operation at various stages for traceability. +func (r *PostgreSQLUserRepository) GetByRole(ctx context.Context, role models.UserRole, limit, offset int) ([]*models.User, error) { + logger.Info(constants.LogGetUsersByRoleStart, "role", role) + + var users []*models.User + // Use GORM to find users by role with limit and offset + if err := r.dbd.WithContext(ctx).Where("role = ? AND deleted = false", role).Limit(limit).Offset(offset).Find(&users).Error; err != nil { + logger.Error(constants.ErrGetUsersByRole, "error", err, "role", role) + return nil, errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrGetUsersByRole, err)) + } + // Check if no users were found + if len(users) == 0 { + logger.Warn(constants.ErrNoUsersFound, "role", role) + return nil, errors.NewRepositoryError(constants.ErrNoUsersFound) + } + + logger.Info(constants.LogGetUsersByRoleSuccess, "role", role, "returnedCount", len(users)) + return users, nil +} + +// Delete removes a user and related data from users, userprofile, and addresses tables. +// +// Parameters: +// - keycloakUserID: The unique identifier of the user in Keycloak. +// +// Returns: +// - An error if the operation fails during any step of the deletion process. +// +// Description: +// This method deletes a user and their related data from the database. It first checks if the user exists. +// If the user exists, it uses a GORM transaction to ensure atomicity while deleting the user profile, +// associated address, and the user record. If any step fails, the transaction is rolled back, and an +// appropriate error is returned. The method uses centralized error handling and logs the operation at +// various stages for traceability. +func (r *PostgreSQLUserRepository) Delete(ctx context.Context, keycloakUserID string) error { + logger.Info(constants.LogDeleteUserStart, "keycloak_user_id", keycloakUserID) + + // First check if user exists + var user models.User + if err := r.dbd.Where("keycloak_user_id = ?", keycloakUserID).First(&user).Error; err != nil { + if err == gorm.ErrRecordNotFound { + logger.Error(constants.ErrUserNotFound, "keycloak_user_id", keycloakUserID) + return errors.NewRepositoryError(fmt.Sprintf("%s: %s", constants.ErrUserNotFound, keycloakUserID)) + } + logger.Error(constants.ErrCheckUserExistenceFailed, "error", err, "keycloak_user_id", keycloakUserID) + return errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrCheckUserExistenceFailed, err)) + } + + // Check if user is already inactive + if user.Deleted { + logger.Warn(constants.LogUserAlreadyDeleted, "keycloak_user_id", keycloakUserID) + return errors.NewRepositoryError(fmt.Sprintf("User is already deleted: %s", keycloakUserID)) + } + + // Perform soft delete by setting deleted to true + logger.Info(constants.LogSoftDeleteUserStart, "keycloak_user_id", keycloakUserID) + if err := r.dbd.Model(&models.User{}). + Where("keycloak_user_id = ?", keycloakUserID). + Update("deleted", true).Error; err != nil { + logger.Error(constants.ErrSoftDeleteUserFailed, "error", err, "keycloak_user_id", keycloakUserID) + return errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrSoftDeleteUserFailed, err)) + } + + logger.Info(constants.LogSoftDeleteUserSuccess, "keycloak_user_id", keycloakUserID) + return nil +} + +// GetAddressByID retrieves an address by its ID. +// +// Parameters: +// - addressID: The unique identifier of the address to retrieve. +// +// Returns: +// - A pointer to the AddressDB object if found. +// - An error if the operation fails or the address is not found. +// +// Description: +// This method queries the database to retrieve the address associated with the given address ID. +// If the address is not found, it returns an error. The method uses centralized error handling +// and logs the operation at various stages for traceability. +func (r *PostgreSQLUserRepository) GetAddressByID(ctx context.Context, addressID string) (*models.AddressDB, error) { + logger.Info(constants.LogRetrieveAddressStart, "addressID", addressID) + + var address models.AddressDB + // Use GORM to find the address by ID + if err := r.dbd.WithContext(ctx).Where("id = ?", addressID).First(&address).Error; err != nil { + if err == gorm.ErrRecordNotFound { + logger.Error(constants.ErrAddressNotFound, "addressID", addressID) + return nil, errors.NewRepositoryError(fmt.Sprintf("%s: %s", constants.ErrAddressNotFound, addressID)) + } + logger.Error(constants.ErrRetrieveAddressFailed, "error", err, "addressID", addressID) + return nil, errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrRetrieveAddressFailed, err)) + } + + logger.Info(constants.LogRetrieveAddressSuccess, "addressID", addressID) + return &address, nil +} + +// Count returns the total number of users in the database. +// +// Returns: +// - The total number of users as an int64. +// - An error if the operation fails. +// +// Description: +// This method uses GORM to count the total number of users in the database. If an error occurs +// during the counting process, it returns an appropriate error message. +func (r *PostgreSQLUserRepository) Count() (int64, error) { + logger.Info(constants.LogCountUsersStart) + + var count int64 + // Use GORM to count the number of users + if err := r.dbd.Model(&models.User{}).Count(&count).Error; err != nil { + logger.Error(constants.ErrCountUsersFailed, "error", err) + return 0, errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrCountUsersFailed, err)) + } + + logger.Info(constants.LogCountUsersSuccess, "count", count) + return count, nil +} + +// UpdateUser updates user basic information including preferred language. +// +// Parameters: +// - keycloakUserID: The unique identifier of the user in Keycloak. +// - updateReq: A pointer to the UpdateUserRequest object containing the updated user information. +// +// Returns: +// - An error if the operation fails. +// +// Description: +// This method updates the basic information of a user in the database, including their preferred language. +// If the update operation fails, it returns an appropriate error message. The method uses centralized +// error handling and logs the operation at various stages for traceability. +func (r *PostgreSQLUserRepository) UpdateUser(ctx context.Context, keycloakUserID string, updateReq *models.UpdateUserRequest) error { + logger.Info(constants.LogUpdateUserStartt, "keycloak_user_id", keycloakUserID) + + // Use GORM to update the user + if err := r.dbd.WithContext(ctx).Model(&models.User{}).Where("keycloak_user_id = ? AND is_active= true", keycloakUserID).Updates(models.User{ + Email: updateReq.Email, + Role: models.UserRole(updateReq.Role), + IsActive: updateReq.IsActive, + PreferredLanguage: updateReq.PreferredLanguage, + EndDate: updateReq.EndDate, + }).Error; err != nil { + logger.Error(constants.ErrUpdateUserFailed, "error", err, "keycloak_user_id", keycloakUserID) + return errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrUpdateUserFailed, err)) + } + + logger.Info(constants.LogUpdateUserSuccess, "keycloak_user_id", keycloakUserID) + return nil +} + +// UpdateUserProfileComplete updates complete user profile. +// +// Parameters: +// - keycloakUserID: The unique identifier of the user in Keycloak. +// - profile: A pointer to the UpdateUserProfile object containing the updated profile information. +// +// Returns: +// - An error if the operation fails. +// +// Description: +// This method updates the complete user profile in the database. If the user profile does not have an +// associated address, it creates a new address record and updates the profile with the new AddressID. +// If an address already exists, it updates the existing address record. The method uses centralized +// error handling and logs the operation at various stages for traceability. +func (r *PostgreSQLUserRepository) UpdateUserProfileComplete(ctx context.Context, keycloakUserID string, profile *models.UpdateUserProfile) error { + logger.Info(constants.LogUpdateUserProfileStart, "keycloak_user_id", keycloakUserID) + + // Update the user profile + if err := r.dbd.WithContext(ctx).Model(&models.UserProfile{}).Where("user_profile_id = ?", keycloakUserID).Updates(profile).Error; err != nil { + logger.Error(constants.ErrUpdateUserProfileFailed, "error", err, "keycloak_user_id", keycloakUserID) + return errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrUpdateUserProfileFailed, err)) + } + + // Retrieve the AddressID from the user_profiles table + var userProfile models.UserProfile + if err := r.dbd.WithContext(ctx).Select("address_id").Where("user_profile_id = ?", keycloakUserID).First(&userProfile).Error; err != nil { + logger.Error(constants.ErrRetrieveAddressIDFailed, "error", err, "keycloak_user_id", keycloakUserID) + return errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrRetrieveAddressIDFailed, err)) + } + + // If AddressID is NULL, create a new address record + if userProfile.AddressID == nil && profile.Address != nil { + logger.Info(constants.LogCreateNewAddressStart, "keycloak_user_id", keycloakUserID) + + // Generate a new UUID for the address ID + newAddress := profile.Address + newAddress.ID = uuid.New().String() // Generate a valid UUID + + // Create a new address record + if err := r.dbd.WithContext(ctx).Create(newAddress).Error; err != nil { + logger.Error(constants.ErrCreateNewAddressFailed, "error", err, "keycloak_user_id", keycloakUserID) + return errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrCreateNewAddressFailed, err)) + } + + // Update the user_profiles table with the new AddressID + if err := r.dbd.WithContext(ctx).Model(&models.UserProfile{}).Where("user_profile_id = ?", keycloakUserID).Update("address_id", newAddress.ID).Error; err != nil { + logger.Error(constants.ErrUpdateAddressIDFailed, "error", err, "keycloak_user_id", keycloakUserID) + return errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrUpdateAddressIDFailed, err)) + } + + logger.Info(constants.LogCreateNewAddressSuccess, "address_id", newAddress.ID, "keycloak_user_id", keycloakUserID) + } else if userProfile.AddressID != nil && profile.Address != nil { + + // If AddressID exists, update the existing address record + logger.Info(constants.LogUpdateExistingAddressStart, "address_id", *userProfile.AddressID) + if err := r.dbd.WithContext(ctx).Model(&models.AddressDB{}).Where("id = ?", *userProfile.AddressID).Updates(profile.Address).Error; err != nil { + logger.Error(constants.ErrUpdateExistingAddressFailed, "error", err, "address_id", *userProfile.AddressID) + return errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrUpdateExistingAddressFailed, err)) + } + logger.Info(constants.LogUpdateExistingAddressSuccess, "address_id", *userProfile.AddressID) + } + + logger.Info(constants.LogUpdateUserProfileSuccess, "keycloak_user_id", keycloakUserID) + return nil +} + +// GetAllUsersWithFilters retrieves users with optional filters and pagination. +// +// Parameters: +// - filters: A UserFilters object containing optional filters for the query. +// - limit: The maximum number of users to retrieve. +// - offset: The starting point for pagination. +// +// Returns: +// - A slice of pointers to User objects if found. +// - The total count of users matching the filters. +// - An error if the operation fails. +// +// Description: +// This method queries the database to retrieve users based on the provided filters. It supports +// pagination using the limit and offset parameters. If an error occurs during the query or counting +// process, it returns an appropriate error message. The method uses centralized error handling and +// logs the operation at various stages for traceability. +func (r *PostgreSQLUserRepository) GetAllUsersWithFilters(ctx context.Context, filters models.UserFilters, limit, offset int) ([]*models.User, int64, error) { + logger.Info(constants.LogGetUsersWithFiltersStartRetrive) + + var users []*models.User + var totalCount int64 + query := r.dbd.WithContext(ctx).Model(&models.User{}).Where("deleted = false") + + // Apply filters + if filters.Role != nil { + query = query.WithContext(ctx).Where("role IN ?", filters.Role) + } + if filters.IsActive != nil { + query = query.WithContext(ctx).Where("is_active = ?", *filters.IsActive) + } + if filters.Username != nil && *filters.Username != "" { + query = query.WithContext(ctx).Where("username ILIKE ?", "%"+*filters.Username+"%") + } + if filters.Email != nil && *filters.Email != "" { + query = query.WithContext(ctx).Where("email ILIKE ?", "%"+*filters.Email+"%") + } + + if filters.Ward != nil { + query = query.WithContext(ctx).Joins("JOIN \"DIGIT3\".\"zone_mapping\" ON \"DIGIT3\".\"zone_mapping\".user_id = \"DIGIT3\".\"users\".keycloak_user_id"). + Where("\"DIGIT3\".\"zone_mapping\".ward && ?", pq.Array(filters.Ward)) + } + + if filters.PhoneNumber != nil { + query = query.WithContext(ctx).Joins("JOIN \"DIGIT3\".\"user_profiles\" ON \"DIGIT3\".\"users\".keycloak_user_id = \"DIGIT3\".\"user_profiles\".user_profile_id"). + Where("\"DIGIT3\".\"user_profiles\".phone_number = ?", *filters.PhoneNumber) + } + + // Count total records + if err := query.WithContext(ctx).Count(&totalCount).Error; err != nil { + logger.Error(constants.ErrCountUsersWithFiltersFailed, "error", err) + return nil, 0, errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrCountUsersWithFiltersFailed, err)) + } + + // Apply pagination and retrieve users + if err := query.WithContext(ctx).Limit(limit).Offset(offset).Find(&users).Error; err != nil { + logger.Error(constants.ErrRetrieveUsersWithFiltersFailed, "error", err) + return nil, 0, errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrRetrieveUsersWithFiltersFailed, err)) + } + logger.Info(constants.LogGetUsersWithFiltersSuccess, "totalCount", totalCount, "returnedCount", len(users), "limit", limit, "offset", offset) + return users, totalCount, nil +} + +// UpdateUserIsActive updates the isActive field for a user in the database. +// +// Parameters: +// - ctx: The context for managing request-scoped values. +// - userID: The unique identifier of the user in Keycloak. +// - isActive: A boolean value indicating the new `isActive` status. +// +// Returns: +// - An error if the operation fails, or nil if the update is successful. +// +// Description: +// This method updates the `is_active` field for a user in the database using the provided `userID`. +// It ensures that the user's active status in the database is consistent with their status in Keycloak. +func (repo *PostgreSQLUserRepository) UpdateUserIsActive(ctx context.Context, userID string, isActive bool) error { + // Use GORM to update the isActive field + if err := repo.dbd.WithContext(ctx). + Model(&models.User{}). + Where("keycloak_user_id = ?", userID). + Update("is_active", isActive).Error; err != nil { + return fmt.Errorf("failed to update isActive for user %s: %w", userID, err) + } + return nil +} + +// GetUsersByStartDate retrieves users whose `start_date` matches the given date and are currently inactive. +// +// Parameters: +// - ctx: The context for managing request-scoped values. +// - date: The date to match against the `start_date` field (format: "YYYY-MM-DD"). +// +// Returns: +// - A slice of `User` objects representing the users to be activated. +// - An error if the database query fails. +// +// Description: +// This method queries the database to find users whose `start_date` matches the provided date +// and whose `is_active` status is `false`. +func (repo *PostgreSQLUserRepository) GetUsersByStartDate(ctx context.Context, date string) ([]models.User, error) { + var users []models.User + err := repo.dbd.WithContext(ctx). + Where("start_date = ? AND is_active = false", date). + Find(&users).Error + return users, err +} + +// GetUsersByEndDate retrieves users whose `end_date` is less than or equal to the given date and are currently active. +// +// Parameters: +// - ctx: The context for managing request-scoped values. +// - date: The date to match against the `end_date` field (format: "YYYY-MM-DD"). +// +// Returns: +// - A slice of `User` objects representing the users to be deactivated. +// - An error if the database query fails. +// +// Description: +// This method queries the database to find users whose `end_date` is less than or equal to the provided date +// and whose `is_active` status is `true`. +func (repo *PostgreSQLUserRepository) GetUsersByEndDate(ctx context.Context, date string) ([]models.User, error) { + var users []models.User + err := repo.dbd.WithContext(ctx). + Where("end_date <= ? AND is_active = true", date). + Find(&users).Error + return users, err +} + +// GetUsersWithFutureStartDate retrieves users whose `start_date` is after the given date and are currently active. +// +// Parameters: +// - ctx: The context for managing request-scoped values. +// - currentDate: The current date to compare against the `start_date` field (format: "YYYY-MM-DD"). +// +// Returns: +// - A slice of `User` objects representing the users to be deactivated temporarily. +// - An error if the database query fails. +// +// Description: +// This method queries the database to find users whose `start_date` is after the provided date +// and whose `is_active` status is `true`. These users are candidates for temporary deactivation. +func (repo *PostgreSQLUserRepository) GetUsersWithFutureStartDate(ctx context.Context, currentDate string) ([]models.User, error) { + var users []models.User + err := repo.dbd.WithContext(ctx). + Where("start_date > ? AND is_active = true", currentDate). + Find(&users).Error + return users, err +} + +func (repo *PostgreSQLUserRepository) GetUserCounts(ctx context.Context) (int64, int64, int64, error) { + var totalUsers, activeUsers, fieldAgents int64 + + // Count total users (excluding deleted) + if err := repo.dbd.WithContext(ctx).Model(&models.User{}).Where("deleted = ?", false).Count(&totalUsers).Error; err != nil { + logger.Error("Failed to count total users", "error", err) + return 0, 0, 0, err + } + + // Count active users + if err := repo.dbd.WithContext(ctx).Model(&models.User{}).Where("deleted = ? AND is_active = ?", false, true).Count(&activeUsers).Error; err != nil { + logger.Error("Failed to count active users", "error", err) + return 0, 0, 0, err + } + + // Count field agents + if err := repo.dbd.WithContext(ctx).Model(&models.User{}).Where("deleted = ? AND role = ?", false, models.RoleAgent).Count(&fieldAgents).Error; err != nil { + logger.Error("Failed to count field agents", "error", err) + return 0, 0, 0, err + } + logger.Info("User counts retrieved successfully", "totalUsers", totalUsers, "activeUsers", activeUsers, "fieldAgents", fieldAgents) + return totalUsers, activeUsers, fieldAgents, nil +} \ No newline at end of file diff --git a/backend/onboarding/internal/repositories/zone_mapping_repo.go b/backend/onboarding/internal/repositories/zone_mapping_repo.go new file mode 100644 index 0000000..f28fd7f --- /dev/null +++ b/backend/onboarding/internal/repositories/zone_mapping_repo.go @@ -0,0 +1,53 @@ +package repositories + +import ( + "context" + "fmt" + "property-tax-onboarding/internal/models" + "property-tax-onboarding/pkg/logger" + "gorm.io/gorm" +) + +// ZoneMappingRepository defines the interface for zone mapping database operations +type ZoneMappingRepository interface { + CreateZoneMapping(ctx context.Context, mapping *models.ZoneMapping) error + GetZoneMappingsByUser(ctx context.Context, userID string) ([]models.ZoneMapping, error) +} + +// PostgreSQLZoneMappingRepository handles operations related to zone mappings +type PostgreSQLZoneMappingRepository struct { + db *gorm.DB +} + +// NewPostgreSQLZoneMappingRepository creates a new instance of PostgreSQLZoneMappingRepository +func NewPostgreSQLZoneMappingRepository(db *gorm.DB) *PostgreSQLZoneMappingRepository { + return &PostgreSQLZoneMappingRepository{db: db} +} + +// CreateZoneMapping inserts a new zone mapping for a user +func (r *PostgreSQLZoneMappingRepository) CreateZoneMapping(ctx context.Context, mapping *models.ZoneMapping) error { + logger.Info("Creating zone mapping", "user_id", mapping.UserID, "zone", mapping.Zone) + + if err := r.db.WithContext(ctx).Create(mapping).Error; err != nil { + logger.Error("Failed to create zone mapping", "error", err, "user_id", mapping.UserID, "zone", mapping.Zone) + return fmt.Errorf("failed to create zone mapping: %w", err) + } + + logger.Info("Zone mapping created successfully", "user_id", mapping.UserID, "zone", mapping.Zone) + return nil +} + +// GetZoneMappingsByUser retrieves zone mappings for a specific user +func (r *PostgreSQLZoneMappingRepository) GetZoneMappingsByUser(ctx context.Context, userID string) ([]models.ZoneMapping, error) { + logger.Info("Fetching zone mappings for user", "user_id", userID) + + var mappings []models.ZoneMapping + if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&mappings).Error; err != nil { + logger.Error("Failed to fetch zone mappings for user", "error", err, "user_id", userID) + return nil, fmt.Errorf("failed to fetch zone mappings: %w", err) + } + + logger.Info("Zone mappings fetched successfully", "user_id", userID) + return mappings, nil +} + diff --git a/backend/onboarding/internal/routes/routes.go b/backend/onboarding/internal/routes/routes.go new file mode 100644 index 0000000..7d1c049 --- /dev/null +++ b/backend/onboarding/internal/routes/routes.go @@ -0,0 +1,36 @@ +// Package routes defines HTTP route configuration for the onboarding service. +// This file sets up all API endpoints and maps them to handler functions. +package routes + +import ( + "property-tax-onboarding/internal/handlers" + "github.com/gin-gonic/gin" +) + +// SetupRoutes configures all application routes for the onboarding service. +// It registers health check and user management endpoints, and groups API v1 routes. +// +// Parameters: +// - router: Gin engine instance to register routes on +// - userHandler: Handler for user-related endpoints +func SetupRoutes(router *gin.Engine, userHandler *handlers.UserHandler) { + // Health check endpoint to verify service status + router.GET("/health", userHandler.HealthCheck) + + // API v1 routes group all versioned endpoints under /api/v1 + v1 := router.Group("/api/v1") + { + // Unified user routes (supports both agents and citizens via role in payload) + // Provides endpoints for user CRUD operations and role-based queries + users := v1.Group("/users") + { + users.GET("", userHandler.GetAllUsers) // Get all users with optional filters (role, status, etc.) + users.POST("", userHandler.CreateUser) // Create a new user (agent or citizen) + users.GET("/count", userHandler.GetUserCounts) + users.GET("/:id", userHandler.GetUser) // Retrieve user details by user ID + users.PUT("/:id", userHandler.UpdateUser) // Update all user information by user ID + users.GET("/by-role/:role", userHandler.GetUsersByRole) // Retrieve users by specific role + users.DELETE("/:id", userHandler.DeleteUser) // Delete user by user ID + } + } +} diff --git a/backend/onboarding/internal/scheduler/scheduler.go b/backend/onboarding/internal/scheduler/scheduler.go new file mode 100644 index 0000000..2fa03a0 --- /dev/null +++ b/backend/onboarding/internal/scheduler/scheduler.go @@ -0,0 +1,33 @@ +// Package scheduler provides background job scheduling for the onboarding service. +// This file sets up cron jobs for periodic user activation and deactivation. +package scheduler + +import ( + "log" // For logging job execution + "property-tax-onboarding/internal/repositories" // User repository interface + "property-tax-onboarding/internal/services" // Keycloak service + "github.com/robfig/cron/v3" // Cron job scheduling library +) + +// ScheduleUserActivation sets up a scheduled cron job for user activation/deactivation. +// Runs the ManageUserActivation method on the KeycloakService at the specified interval. +// Parameters: +// - keycloakService: Service for managing Keycloak users +// - userRepo: User repository for DB operations (not used directly here) +func ScheduleUserActivation(keycloakService *services.KeycloakService, userRepo repositories.UserRepository) { + c := cron.New() + + // Schedule the task to run every day at midnight + c.AddFunc("*/2 * * * *", func() { + log.Println("Running daily user activation/deactivation task...") + + // Call the service layer to handle user activation/deactivation + err := keycloakService.ManageUserActivation() + if err != nil { + log.Printf("Error in user activation/deactivation task: %v\n", err) + } + }) + + // Start the cron scheduler + c.Start() +} diff --git a/backend/onboarding/internal/services/keycloak_service.go b/backend/onboarding/internal/services/keycloak_service.go new file mode 100644 index 0000000..68a118a --- /dev/null +++ b/backend/onboarding/internal/services/keycloak_service.go @@ -0,0 +1,676 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "property-tax-onboarding/internal/constants" + "property-tax-onboarding/internal/errors" + "property-tax-onboarding/internal/models" + "property-tax-onboarding/internal/repositories" + "property-tax-onboarding/pkg/logger" + "strings" + "time" + "github.com/cenkalti/backoff/v4" +) + +// KeycloakService handles interactions with the Keycloak identity provider. +// +// This service provides methods for managing users, roles, and tokens in Keycloak. +// It uses HTTP requests to interact with Keycloak's REST API. +type KeycloakService struct { + BaseURL string // Base URL of the Keycloak server. + TokenURL string // URL for obtaining admin tokens. + UserURL string // URL for managing users. + AssignRoleURL string // URL for assigning roles to users. + RoleURL string // URL for retrieving a specific role. + RolesURL string // URL for retrieving roles assigned to a user. + DeleteURL string // URL for deleting users. + AdminUser string // Admin username for authentication. + AdminPass string // Admin password for authentication. + ClientID string // Client ID for authentication. + ClientSecret string // Client secret for authentication. + Realm string // Keycloak realm to operate in. + httpClient *http.Client // HTTP client for making requests. + userRepo repositories.UserRepository // Repository for user data operations. +} + +// KeycloakTokenResponse represents the token response from Keycloak. +// +// This struct is used to parse the JSON response when requesting an admin token. +type KeycloakTokenResponse struct { + AccessToken string `json:"access_token"` // The access token. + TokenType string `json:"token_type"` // The type of the token (e.g., Bearer). + ExpiresIn int `json:"expires_in"` // Token expiration time in seconds. +} + +// NewKeycloakService creates a new instance of KeycloakService. +// +// Returns: +// - A new instance of KeycloakService. +func NewKeycloakService(baseURL, tokenURL, userURL, assignRoleURL, roleURL, rolesURL, deleteURL, adminUser, adminPass, clientID, clientSecret, realm string, userRepo repositories.UserRepository) *KeycloakService { + return &KeycloakService{ + BaseURL: baseURL, + TokenURL: tokenURL, + UserURL: userURL, + AssignRoleURL: assignRoleURL, + RoleURL: roleURL, + RolesURL: rolesURL, + DeleteURL: deleteURL, + AdminUser: adminUser, + AdminPass: adminPass, + ClientID: clientID, + ClientSecret: clientSecret, + Realm: realm, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + userRepo: userRepo, + } +} + +// GetAdminToken retrieves an admin access token from Keycloak. +// +// Returns: +// - A string containing the admin access token. +// - An error if the operation fails. +// +// Description: +// This method sends a POST request to the Keycloak token endpoint to obtain an admin token. +// It uses exponential backoff for retries in case of transient errors. +func (ks *KeycloakService) GetAdminToken() (string, error) { + tokenURL := ks.TokenURL + data := url.Values{} + data.Set("grant_type", "password") + data.Set("client_id", "admin-cli") + data.Set("username", ks.AdminUser) + data.Set("password", ks.AdminPass) + + var tokenResp KeycloakTokenResponse + operation := func() error { + req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode())) + if err != nil { + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (url: %s)", constants.ErrCreateToken, tokenURL)) + logger.Error(constants.ErrCreateToken, errMsg) + return errMsg + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := ks.httpClient.Do(req) + if err != nil { + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (url: %s)", constants.ErrRequestToken, tokenURL)) + logger.Error(constants.ErrRequestToken, errMsg) + return errMsg + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (status: %d, url: %s): %s", constants.ErrGetAdminToken, resp.StatusCode, tokenURL, string(body))) + logger.Error(constants.ErrGetAdminToken, errMsg) + return errMsg + } + + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (url: %s)", constants.ErrDecodeToken, tokenURL)) + logger.Error(constants.ErrDecodeToken, errMsg) + return errMsg + } + return nil + } + + // Retry with exponential backoff + expBackoff := backoff.NewExponentialBackOff() + expBackoff.MaxElapsedTime = 2 * time.Minute // Set max retry duration + if err := backoff.Retry(operation, expBackoff); err != nil { + return "", err + } + + return tokenResp.AccessToken, nil +} + +// ManageUserActivation manages the activation and deactivation of users based on their start_date and end_date. +// +// Returns: +// - An error if any operation (fetching users, enabling, or disabling) fails, or nil if all operations succeed. +//Description: +// This method performs the following operations: +// 1. Enables users whose `start_date` is today. +// 2. Disables users whose `end_date` is today. +// 3. Disables users whose `start_date` is in the future. +func (ks *KeycloakService) ManageUserActivation() error { + ctx := context.Background() + today := time.Now().Format("2006-01-02") + + // Enable users with start_date = today + usersToEnable, err := ks.userRepo.GetUsersByStartDate(ctx, today) + if err != nil { + return fmt.Errorf("error fetching users to enable: %w", err) + } + for _, user := range usersToEnable { + if err := ks.EnableUser(user.KeycloakUserID); err != nil { + return fmt.Errorf("error enabling user %s: %w", user.KeycloakUserID, err) + } + } + + // Disable users with end_date = today + usersToDisable, err := ks.userRepo.GetUsersByEndDate(ctx, today) + if err != nil { + return fmt.Errorf("error fetching users to disable: %w", err) + } + for _, user := range usersToDisable { + if err := ks.DisableUser(user.KeycloakUserID); err != nil { + return fmt.Errorf("error disabling user %s: %w", user.KeycloakUserID, err) + } + } + + // Deactivate users with start_date > today + usersWithFutureStartDate, err := ks.userRepo.GetUsersWithFutureStartDate(ctx, today) + if err != nil { + return fmt.Errorf("error fetching users with future start_date: %w", err) + } + for _, user := range usersWithFutureStartDate { + if err := ks.DisableUser(user.KeycloakUserID); err != nil { + return fmt.Errorf("error disabling user with future start_date %s: %w", user.KeycloakUserID, err) + } + } + + return nil +} + +// CreateUser creates a new user in Keycloak. +// +// Parameters: +// - user: A KeycloakUser object containing the user's details. +// +// Returns: +// - A string containing the ID of the created user. +// - An error if the operation fails. +// +// Description: +// This method sends a POST request to the Keycloak user endpoint to create a new user. +// It validates the input, retrieves an admin token, and handles errors appropriately. +func (ks *KeycloakService) CreateUser(user models.KeycloakUser) (string, error) { + if user.Username == "" { + errMsg := errors.NewKeycloakServiceError(constants.ErrEmptyUsername) + logger.Error(constants.ErrEmptyUsername, errMsg) + return "", errMsg + } + if user.Email == "" { + errMsg := errors.NewKeycloakServiceError(constants.ErrEmptyEmail) + logger.Error(constants.ErrEmptyEmail, errMsg) + return "", errMsg + } + token, err := ks.GetAdminToken() + if err != nil { + errMsg := errors.NewKeycloakServiceError(constants.ErrGetAdminToken) + logger.Error(constants.ErrGetAdminToken, errMsg) + return "", errMsg + } + + userURL := ks.UserURL + + userJSON, err := json.Marshal(user) + if err != nil { + errMsg := errors.NewKeycloakServiceError(constants.ErrMarshalUserData) + logger.Error(constants.ErrMarshalUserData, errMsg) + return "", errMsg + } + + req, err := http.NewRequest("POST", userURL, bytes.NewBuffer(userJSON)) + if err != nil { + errMsg := errors.NewKeycloakServiceError(constants.ErrCreateUserRequest) + logger.Error(constants.ErrCreateUserRequest, errMsg) + return "", errMsg + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := ks.httpClient.Do(req) + if err != nil { + errMsg := errors.NewKeycloakServiceError(constants.ErrCreateUser) + logger.Error(constants.ErrCreateUser, errMsg) + return "", errMsg + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (status: %d, url: %s): %s", constants.ErrCreateUser, resp.StatusCode, userURL, string(body))) + logger.Error(constants.ErrCreateUser, errMsg) + return "", errMsg + } + + // Extract user ID from Location header + location := resp.Header.Get("Location") + if location == "" { + errMsg := errors.NewKeycloakServiceError(constants.ErrNoLocationHeader) + logger.Error(constants.ErrNoLocationHeader, errMsg) + return "", errMsg + } + + // Extract user ID from the location URL + parts := strings.Split(location, "/") + if len(parts) == 0 { + errMsg := errors.NewKeycloakServiceError(constants.ErrInvalidLocation) + logger.Error(constants.ErrInvalidLocation, errMsg) + return "", errMsg + } + + return parts[len(parts)-1], nil +} + +// AssignRoleToUser assigns a role to a user in Keycloak. +// +// Parameters: +// - userID: The ID of the user to whom the role will be assigned. +// - roleName: The name of the role to assign. +// +// Returns: +// - An error if the operation fails. +// +// Description: +// This method retrieves the role details and sends a POST request to assign the role to the user. +func (ks *KeycloakService) AssignRoleToUser(userID, roleName string) error { + if userID == "" { + errMsg := errors.NewKeycloakServiceError(constants.ErrEmptyUserID) + logger.Error(constants.ErrEmptyUserID, errMsg) + return errMsg + } + if roleName == "" { + errMsg := errors.NewKeycloakServiceError(constants.ErrEmptyRoleName) + logger.Error(constants.ErrEmptyRoleName, errMsg) + return errMsg + } + token, err := ks.GetAdminToken() + if err != nil { + errMsg := errors.NewKeycloakServiceError(constants.ErrGetAdminToken) + logger.Error(constants.ErrGetAdminToken, errMsg) + return errMsg + } + + // Get role details + role, err := ks.GetRole(roleName) + if err != nil { + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s: '%s': %v", constants.ErrGetRole, roleName, err)) + logger.Error(fmt.Sprintf("%s %s", constants.ErrGetRole, roleName), errMsg) + return errMsg + } + + // Assign role to user + assignURL := fmt.Sprintf(ks.AssignRoleURL, userID) + + roleMapping := []models.KeycloakRole{*role} + roleMappingJSON, err := json.Marshal(roleMapping) + if err != nil { + errMsg := errors.NewKeycloakServiceError(constants.ErrMarshalRole) + logger.Error(constants.ErrMarshalRole, errMsg) + return errMsg + } + + req, err := http.NewRequest("POST", assignURL, bytes.NewBuffer(roleMappingJSON)) + if err != nil { + errMsg := errors.NewKeycloakServiceError(constants.ErrAssignRoleRequest) + logger.Error(constants.ErrAssignRoleRequest, errMsg) + return errMsg + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := ks.httpClient.Do(req) + if err != nil { + errMsg := errors.NewKeycloakServiceError(constants.ErrAssignRole) + logger.Error(constants.ErrAssignRole, errMsg) + return errMsg + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Body) + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (status: %d, url: %s): %s", constants.ErrAssignRole, resp.StatusCode, assignURL, string(body))) + logger.Error(constants.ErrAssignRole, errMsg) + return errMsg + } + + return nil +} + +// GetRole retrieves a role by name from Keycloak. +// +// Parameters: +// - roleName: The name of the role to retrieve. +// +// Returns: +// - A pointer to a KeycloakRole object containing the role details. +// - An error if the operation fails. +// +// Description: +// This method sends a GET request to the Keycloak role endpoint to retrieve the role details. +func (ks *KeycloakService) GetRole(roleName string) (*models.KeycloakRole, error) { + token, err := ks.GetAdminToken() + if err != nil { + errMsg := errors.NewKeycloakServiceError(constants.ErrGetAdminToken) + logger.Error(constants.ErrGetAdminToken, errMsg) + return nil, errMsg + } + + roleURL := fmt.Sprintf(ks.RoleURL, roleName) + + req, err := http.NewRequest("GET", roleURL, nil) + if err != nil { + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (url: %s)", constants.ErrCreateRoleRequest, roleURL)) + logger.Error(constants.ErrCreateRoleRequest, errMsg) + return nil, errMsg + } + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := ks.httpClient.Do(req) + if err != nil { + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (url: %s)", constants.ErrGetRole, roleURL)) + logger.Error(constants.ErrGetRole, errMsg) + return nil, errMsg + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (status: %d, url: %s): %s", constants.ErrGetRole, resp.StatusCode, roleURL, string(body))) + logger.Error(constants.ErrGetRole, errMsg) + return nil, errMsg + } + + var role models.KeycloakRole + if err := json.NewDecoder(resp.Body).Decode(&role); err != nil { + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (url: %s)", constants.ErrDecodeToken, roleURL)) + logger.Error(constants.ErrDecodeToken, errMsg) + return nil, errMsg + } + + return &role, nil +} + +// GetUserByID retrieves a Keycloak user by their unique user ID. +// +// Parameters: +// - userID: The unique identifier of the user in Keycloak. +// +// Returns: +// - A pointer to the `KeycloakUser` object containing the user's details. +// - An error if the operation fails, or if the user is expired. +// +// Description: +// This method performs the following operations: +// 1. Fetches the user details from Keycloak using the Keycloak Admin API. +// 2. Validates the `expiryDate` attribute (if present) to check if the user is expired. +// 3. If the user is expired, disables the user in Keycloak and returns an error. +func (ks *KeycloakService) GetUserByID(userID string) (*models.KeycloakUser, error) { + token, err := ks.GetAdminToken() + if err != nil { + return nil, fmt.Errorf(constants.ErrGetAdminToken) + } + url := fmt.Sprintf("%s/admin/realms/%s/users/%s", ks.BaseURL, ks.Realm, userID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf(constants.ErrCreateUserRequest) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf(constants.ErrFetchKeycloakUser) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf(constants.ErrKeycloakAPIStatus+": %d", resp.StatusCode) + } + + var keycloakUser models.KeycloakUser + if err := json.NewDecoder(resp.Body).Decode(&keycloakUser); err != nil { + return nil, fmt.Errorf(constants.ErrDecodeKeycloakUser+": %w", err) + } + + // Check expiryDate + expiryDateInterface, exists := keycloakUser.Attributes["expiryDate"] + if exists { + expiryDateSlice, ok := expiryDateInterface.([]interface{}) + log.Printf("expiryDateInterface: %v, Type: %T\n", expiryDateInterface, expiryDateInterface) + if !ok || len(expiryDateSlice) == 0 { + return nil, fmt.Errorf("expiryDate attribute is missing or invalid for user %s", userID) + } + + expiryDate, err := time.Parse("2006-01-02", expiryDateSlice[0].(string)) + if err != nil { + return nil, fmt.Errorf("invalid expiryDate format for user %s: %w", userID, err) + } + + if time.Now().After(expiryDate) { + // Disable the user in Keycloak + if disableErr := ks.DisableUser(userID); disableErr != nil { + return nil, fmt.Errorf("failed to disable expired user %s: %w", userID, disableErr) + } + + return nil, fmt.Errorf("user %s is expired and has been disabled", userID) + } + } + + return &keycloakUser, nil +} + +// GetUserRoles retrieves roles assigned to a user in Keycloak. +// +// Parameters: +// - userID: The ID of the user whose roles are to be retrieved. +// +// Returns: +// - A slice of KeycloakRole objects representing the user's roles. +// - An error if the operation fails. +// +// Description: +// This method sends a GET request to the Keycloak roles endpoint to retrieve the user's roles. +func (ks *KeycloakService) GetUserRoles(userID string) ([]models.KeycloakRole, error) { + token, err := ks.GetAdminToken() + if err != nil { + errMsg := errors.NewKeycloakServiceError(constants.ErrGetAdminToken) + logger.Error(constants.ErrGetAdminToken, errMsg) + return nil, errMsg + } + + rolesURL := fmt.Sprintf(ks.RolesURL, userID) + + req, err := http.NewRequest("GET", rolesURL, nil) + if err != nil { + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (url: %s)", constants.ErrCreateRoleRequest, rolesURL)) + logger.Error(constants.ErrCreateRoleRequest, errMsg) + return nil, errMsg + } + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := ks.httpClient.Do(req) + if err != nil { + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (url: %s)", constants.ErrGetUserRoles, rolesURL)) + logger.Error(constants.ErrGetUserRoles, errMsg) + return nil, errMsg + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (status: %d, url: %s): %s", constants.ErrGetUserRoles, resp.StatusCode, rolesURL, string(body))) + logger.Error(constants.ErrGetUserRoles, errMsg) + return nil, errMsg + } + + var roles []models.KeycloakRole + if err := json.NewDecoder(resp.Body).Decode(&roles); err != nil { + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (url: %s)", constants.ErrDecodeToken, rolesURL)) + logger.Error(constants.ErrDecodeToken, errMsg) + return nil, errMsg + } + + return roles, nil +} + +// DeleteUser deletes a user from Keycloak. +// +// Parameters: +// - userID: The ID of the user to delete. +// +// Returns: +// - An error if the operation fails. +// +// Description: +// This method sends a DELETE request to the Keycloak user endpoint to delete the specified user. +func (ks *KeycloakService) DeleteUser(userID string) error { + // Get Keycloak token + token, err := ks.GetAdminToken() + if err != nil { + errMsg := errors.NewKeycloakServiceError(constants.ErrGetAdminToken) + logger.Error(constants.ErrGetAdminToken, errMsg) + return errMsg + } + + // Construct URL for user deletion + deleteURL := fmt.Sprintf(ks.DeleteURL, userID) + + // Create DELETE request + req, err := http.NewRequest("DELETE", deleteURL, nil) + if err != nil { + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (url: %s)", constants.ErrDeleteUserRequest, deleteURL)) + logger.Error(constants.ErrDeleteUserRequest, errMsg) + return errMsg + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + // Send request + resp, err := ks.httpClient.Do(req) + if err != nil { + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (url: %s)", constants.ErrDeleteUser, deleteURL)) + logger.Error(constants.ErrDeleteUser, errMsg) + return errMsg + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + errMsg := errors.NewKeycloakServiceError(fmt.Sprintf("%s (status: %d, url: %s): %s", constants.ErrDeleteUserKeycloak, resp.StatusCode, deleteURL, string(body))) + logger.Error(constants.ErrDeleteUserKeycloak, errMsg) + return errMsg + } + + return nil +} + +// EnableUser enables a user in Keycloak and updates their `isActive` status in the database. +// Parameters: +// - userID: The unique identifier of the user in Keycloak. +// +// Returns: +// - An error if any operation fails, or nil if the user is successfully enabled. +// +// Description: +// This function interacts with the Keycloak Admin API to enable a user by setting their `enabled` attribute to `true`. +// It also updates the `isActive` field in the database to reflect the user's active status. +func (ks *KeycloakService) EnableUser(userID string) error { + token, err := ks.GetAdminToken() + if err != nil { + return fmt.Errorf("failed to get admin token: %w", err) + } + + url := fmt.Sprintf("%s/admin/realms/%s/users/%s", ks.BaseURL, ks.Realm, userID) + reqBody := map[string]bool{"enabled": true} + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := http.NewRequest("PUT", url, bytes.NewBuffer(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Content-Type", "application/json") + + resp, err := ks.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to enable user in Keycloak: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + responseBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to enable user in Keycloak, status: %d, response: %s", resp.StatusCode, string(responseBody)) + } + + ctx := context.Background() + if err := ks.userRepo.UpdateUserIsActive(ctx, userID, true); err != nil { + return fmt.Errorf("failed to update isActive in database for user %s: %w", userID, err) + } + + return nil +} + +// DisableUser disables a user in Keycloak and updates their `isActive` status in the database. +// +// Parameters: +// - userID: The unique identifier of the user in Keycloak. +// +// Returns: +// - An error if any operation fails, or nil if the user is successfully disabled. +// +// Description: +// This function interacts with the Keycloak Admin API to disable a user by setting their `enabled` attribute to `false`. +// It also updates the `isActive` field in the database to reflect the user's inactive status. +func (ks *KeycloakService) DisableUser(userID string) error { + token, err := ks.GetAdminToken() + if err != nil { + return fmt.Errorf(constants.ErrGetAdminToken) + } + + url := fmt.Sprintf("%s/admin/realms/%s/users/%s", ks.BaseURL, ks.Realm, userID) + + reqBody := map[string]bool{"enabled": false} + body, _ := json.Marshal(reqBody) + + req, err := http.NewRequest("PUT", url, bytes.NewBuffer(body)) + if err != nil { + return fmt.Errorf(constants.ErrCreateUserRequest) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Content-Type", "application/json") + + resp, err := ks.httpClient.Do(req) + if err != nil { + return fmt.Errorf(constants.ErrFetchKeycloakUser) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf(constants.ErrKeycloakAPIStatus+": %d", resp.StatusCode) + } + + // Update isActive in the database + ctx := context.Background() + if err := ks.userRepo.UpdateUserIsActive(ctx, userID, false); err != nil { + return fmt.Errorf("failed to update isActive in database for user %s: %w", userID, err) + } + + return nil +} diff --git a/backend/onboarding/internal/services/user_service.go b/backend/onboarding/internal/services/user_service.go new file mode 100644 index 0000000..b0e4442 --- /dev/null +++ b/backend/onboarding/internal/services/user_service.go @@ -0,0 +1,754 @@ +// Package services contains the business logic for the application. +// It interacts with repositories, external services, and utilities to perform operations. +package services + +import ( + "context" + "fmt" + "property-tax-onboarding/internal/constants" + "property-tax-onboarding/internal/errors" + "property-tax-onboarding/internal/models" + "property-tax-onboarding/internal/repositories" + "property-tax-onboarding/internal/utils" + "property-tax-onboarding/internal/validator" + "property-tax-onboarding/pkg/logger" + "strings" + "github.com/lib/pq" +) + +// UserService provides methods to manage users, including creation, retrieval, and updates. +// It interacts with Keycloak, the database, and other services. +type UserService struct { + keycloakService *KeycloakService // Service for interacting with Keycloak. + userRepository repositories.UserRepository // Repository for user-related database operations. + zoneMappingRepository repositories.ZoneMappingRepository // Repository for zone mapping operations. + validationService *validator.UserValidationService // Service for validating user-related requests. +} + +// NewUserService creates a new instance of UserService with the provided dependencies. +// +// Parameters: +// - keycloakService: Service for interacting with Keycloak. +// - userRepo: Repository for user-related database operations. +// - validationService: Service for validating user-related requests. +// - zoneMappingRepo: Repository for zone mapping operations. +// +// Returns: +// - A new instance of UserService. +func NewUserService(keycloakService *KeycloakService, userRepo repositories.UserRepository, validationService *validator.UserValidationService, zoneMappingRepo repositories.ZoneMappingRepository) *UserService { + return &UserService{ + keycloakService: keycloakService, + userRepository: userRepo, + validationService: validationService, + zoneMappingRepository: zoneMappingRepo, + } +} + +// CreateUser creates a new user in the system and Keycloak. +// It validates the request, creates the user in Keycloak, assigns roles, and saves the user in the database. +// +// Parameters: +// - ctx: Context for managing request-scoped values. +// - req: Request object containing user details. +// +// Returns: +// - A UserResponse object containing the created user's details. +// - An error if the operation fails. +func (userService *UserService) CreateUser(ctx context.Context, req *models.CreateUserRequest) (*models.UserResponse, error) { + logger.Info(constants.LogCreateUserStart, "username", req.Username, "role", req.Role) + + if req.Role == models.RoleCitizen && (req.Password == "" || req.Password == "null") { + req.Password = "8472" + logger.Info("Set default password for CITIZEN role", "username", req.Username) + } + + // Validate request + if err := userService.validationService.ValidateCreateUserRequest(req); err != nil { + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrValidationFailed, err)) + } + + // Create user in Keycloak + logger.Info(constants.LogCreateUserKeycloak, "username", req.Username) + keycloakUser := userService.buildKeycloakUser(req) + keycloakUserID, err := userService.keycloakService.CreateUser(keycloakUser) + if err != nil { + logger.Error(constants.ErrCreateKeycloakUser, "error", err) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrCreateKeycloakUser, err)) + } + // Flag to track if we need to rollback Keycloak user + keycloakUserCreated := true + defer func() { + // Rollback Keycloak user if database operations fail + if keycloakUserCreated && err != nil { + logger.Warn(constants.LogRollbackKeycloakUser, "keycloakUserID", keycloakUserID) + if deleteErr := userService.keycloakService.DeleteUser(keycloakUserID); deleteErr != nil { + logger.Error(constants.ErrDeleteUserKeycloak, "error", deleteErr, "keycloakUserID", keycloakUserID) + } else { + logger.Info(constants.LogRollbackKeycloakUser, "keycloakUserID", keycloakUserID) + } + } + }() + + // Assign role to user in Keycloak + logger.Info(constants.LogAssignRoleToUser, "userID", keycloakUserID, "role", req.Role) + if err := userService.keycloakService.AssignRoleToUser(keycloakUserID, string(req.Role)); err != nil { + logger.Error(constants.ErrAssignRoleToUser, "error", err, "userID", keycloakUserID, "role", req.Role) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrAssignRoleToUser, err)) + } + + // Start database transaction + logger.Info(constants.LogBeginTransaction) + tx, err := userService.userRepository.BeginTransaction(ctx) + if err != nil { + logger.Error(constants.ErrBeginTransaction, "error", err) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrBeginTransaction, err)) + } + if tx == nil { + err = errors.NewServiceError(constants.ErrBeginTransaction) + return nil, err + } + + defer func() { + if r := recover(); r != nil { + tx.Rollback() + err = errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrBeginTransaction, r)) + logger.Error("error", err) + } + }() + + // Save user to database + logger.Info(constants.LogSaveUserToDatabase, "userID", keycloakUserID) + user, createErr := models.CreateUserFromRequest(req, keycloakUserID) + if createErr != nil { + tx.Rollback() + logger.Error(constants.ErrCreateUserFromRequest, "error", createErr, "userID", keycloakUserID) + err = errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrCreateUserFromRequest, createErr)) + return nil, err + } + + if createErr = tx.Create(user); createErr != nil { + tx.Rollback() + logger.Error(constants.ErrSaveUserToDatabase, "error", createErr, "userID", keycloakUserID) + err = errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrSaveUserToDatabase, createErr)) + return nil, err + } + + // Create UserProfile + logger.Info(constants.LogSaveUserProfile, "userID", keycloakUserID) + userProfile := models.CreateUserProfileFromRequest(&req.Profile, keycloakUserID) + if createErr = tx.CreateUserProfile(userProfile); createErr != nil { + tx.Rollback() + logger.Error(constants.ErrSaveUserProfile, "error", createErr, "userID", keycloakUserID) + err = errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrSaveUserProfile, createErr)) + return nil, err + } + + // Commit transaction + logger.Info(constants.LogCommitTransaction, "userID", keycloakUserID) + if commitErr := tx.Commit(); commitErr != nil { + logger.Error(constants.ErrCommitTransaction, "error", commitErr, "userID", keycloakUserID) + err = errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrCommitTransaction, commitErr)) + return nil, err + } + + // Transaction successful, don't rollback Keycloak user + keycloakUserCreated = false + logger.Info(constants.LogUserCreatedSuccessfully, "userID", keycloakUserID, "role", req.Role) + + // Step 1: Validate zones and wards + logger.Info(constants.LogZoneValidationStart) + for _, zone := range req.ZoneData { + _, err := userService.ValidateZoneAndWards(zone.ZoneNumber, zone.Wards) + if err != nil { + logger.Error(constants.ErrZoneValidationFailed, "error", err, "zone", zone.ZoneNumber) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrZoneValidationFailed, err)) + } + } + + // Step 2: Insert zone mappings + logger.Info(constants.LogInsertZoneMappings, "userID", keycloakUserID) + zoneMappings := make([]models.ZoneMapping, len(req.ZoneData)) + for i, zone := range req.ZoneData { + zoneMappings[i] = models.ZoneMapping{ + Zone: zone.ZoneNumber, + Wards: pq.StringArray(zone.Wards), + } + } + + if err := userService.InsertZoneMappings(ctx, keycloakUserID, zoneMappings); err != nil { + logger.Error(constants.ErrInsertZoneMappings, "error", err, "userID", keycloakUserID) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrInsertZoneMappings, err)) + } + + // Create response + response := &models.UserResponse{} + response.CreateUserResponse(user, userProfile) + + // Fetch and populate zone data + logger.Info(constants.LogFetchZoneMappings, "userID", keycloakUserID) + zoneData, err := userService.GetZoneMappingsByUserID(ctx, keycloakUserID) + if err != nil { + logger.Error(constants.ErrFetchZoneMappings, "error", err, "userID", keycloakUserID) + } else { + response.ZoneData = zoneData + } + + // Fetch and populate address if AddressID exists + if userProfile != nil && userProfile.AddressID != nil { + logger.Info(constants.LogFetchAddress, "userID", keycloakUserID) + userService.populateAddress(ctx, response, *userProfile.AddressID) + } + + return response, nil +} + +// buildKeycloakUser creates a KeycloakUser object from a CreateUserRequest. +// +// Parameters: +// - req: Request object containing user details. +// +// Returns: +// - A KeycloakUser object populated with the request details. +func (userService *UserService) buildKeycloakUser(req *models.CreateUserRequest) models.KeycloakUser { + attributes := map[string]interface{}{ + "phoneNumber": []string{req.Profile.PhoneNumber}, + "userType": []string{string(req.Role)}, + } + + return models.KeycloakUser{ + Username: req.Username, + Email: req.Email, + EmailVerified: true, + FirstName: req.Profile.FirstName, + LastName: req.Profile.LastName, + Enabled: true, + Attributes: attributes, + Credentials: []models.KeycloakCredential{ + { + Type: "password", + Value: req.Password, + Temporary: false, + }, + }, + } +} + +// GetUser retrieves a user by their identifier (Keycloak ID or username). +// +// Parameters: +// - ctx: Context for managing request-scoped values. +// - identifier: The unique identifier of the user (Keycloak ID or username). +// +// Returns: +// - A UserResponse object containing the user's details. +// - An error if the operation fails. +func (userService *UserService) GetUser(ctx context.Context, identifier string) (*models.UserResponse, error) { + logger.Info(constants.LogRetrieveUserStart, "identifier", identifier) + + // Check if userRepository is available + if userService.userRepository == nil { + return nil, errors.NewServiceError(constants.ErrUserRepositoryUnavailable) + } + + var user *models.User + var err error + + // Try to get user by Keycloak ID first, then by username + if len(identifier) > 30 { + user, err = userService.userRepository.GetByKeycloakUserID(ctx, identifier) + } else { + logger.Error(constants.LogRetrieveUserByUsernameFailed, "identifier", identifier) + } + + if err != nil { + logger.Error(constants.ErrUserNotFound, "error", err, "identifier", identifier) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrUserNotFound, err)) + } + + // Check if the user is enabled in Keycloak + keycloakUser, err := userService.keycloakService.GetUserByID(user.KeycloakUserID) + if err != nil { + logger.Error(constants.ErrFetchKeycloakUser, "error", err, "userID", user.KeycloakUserID) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrFetchKeycloakUser, err)) + } + + if !keycloakUser.Enabled { + logger.Warn("User is disabled in Keycloak", "userID", user.KeycloakUserID) + return nil, errors.NewServiceError("User is disabled in Keycloak") + } + + // Get user profile + userProfile, err := userService.userRepository.GetUserProfileByUserID(ctx, user.KeycloakUserID) + if err != nil { + logger.Warn(constants.LogRetrieveUserProfileFailed, "error", err, "userID", user.KeycloakUserID) + userProfile = nil // Continue without profile if not found + } + + // Get user roles from Keycloak + roles, err := userService.keycloakService.GetUserRoles(user.KeycloakUserID) + if err != nil { + logger.Warn(constants.LogRetrieveUserRolesFailed, "error", err, "userID", user.KeycloakUserID) + roles = []models.KeycloakRole{} // Default to empty roles + } + + // Convert roles to string slice + roleNames := make([]string, len(roles)) + for i, role := range roles { + roleNames[i] = role.Name + } + + // Create the user response + response := &models.UserResponse{} + response.CreateUserResponse(user, userProfile) // Use new signature with userProfile + + // Fetch and populate zone data + zoneData, err := userService.GetZoneMappingsByUserID(ctx, user.KeycloakUserID) + if err != nil { + logger.Error(constants.LogFetchZoneMappingsFailed, "error", err, "userID", user.KeycloakUserID) + } else { + response.ZoneData = zoneData + } + + // Fetch and populate address if AddressID exists + if userProfile != nil && userProfile.AddressID != nil { + logger.Info(constants.LogFetchAddress, "userID", user.KeycloakUserID) + userService.populateAddress(ctx, response, *userProfile.AddressID) + } + + logger.Info(constants.LogRetrieveUserSuccess, "userID", user.KeycloakUserID) + return response, nil +} + +// populateAddress fetches address data from the repository and populates the UserResponse object. +// +// Parameters: +// - response: A pointer to the UserResponse object where the address data will be populated. +// - addressID: The unique identifier of the address to fetch. +// +// Description: +// +// This method retrieves the address data corresponding to the provided addressID from the user repository. +// If the address is successfully fetched, it converts the AddressDB object to an Address object and assigns +// it to the Profile.Address field of the UserResponse. If an error occurs during the fetch operation, a +// warning is logged, and the method returns without modifying the response object. +func (userService *UserService) populateAddress(ctx context.Context, response *models.UserResponse, addressID string) { + address, err := userService.userRepository.GetAddressByID(ctx, addressID) + if err != nil { + logger.Warn(constants.ErrFetchAddress, "error", err, "addressID", addressID) + return + } + + // Convert AddressDB to Address for the response + response.Profile.Address = &models.Address{ + AddressLine1: address.AddressLine1, + AddressLine2: address.AddressLine2, + City: address.City, + State: address.State, + PinCode: address.PinCode, + } +} + +// GetUsersByRole retrieves users by their role along with their profiles and associated data. +// +// Parameters: +// - ctx: Context for managing request-scoped values. +// - role: The role of the users to retrieve. +// - limit: The maximum number of users to retrieve. +// - offset: The starting point for pagination. +// +// Returns: +// - A slice of UserResponse objects containing user details, profiles, and associated data. +// - An error if the operation fails. +// +// Description: +// +// This method fetches users based on their role from the user repository. For each user, it retrieves the +// user profile, address (if available), and zone mappings. The retrieved data is used to populate a +// UserResponse object for each user. If any operation fails, appropriate warnings or errors are logged, +// and the method returns an error. +func (s *UserService) GetUsersByRole(ctx context.Context, role models.UserRole, limit, offset int) ([]*models.UserResponse, error) { + logger.Info(constants.LogGetUsersByRoleStart, "role", role, "limit", limit, "offset", offset) + + // Get users by role + users, err := s.userRepository.GetByRole(ctx, role, limit, offset) + if err != nil { + logger.Error(constants.ErrGetUsersByRole, "error", err, "role", role) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrGetUsersByRole, err)) + } + + var userResponses []*models.UserResponse + for _, user := range users { + + keycloakUser, err := s.keycloakService.GetUserByID(user.KeycloakUserID) + if err != nil { + logger.Warn(constants.ErrFetchKeycloakUser, "error", err, "userID", user.KeycloakUserID) + } + + if keycloakUser == nil || !keycloakUser.Enabled { + logger.Warn("Skipping disabled user in Keycloak", "userID", user.KeycloakUserID) + continue // Skip disabled users + } + + // Get user profile + userProfile, err := s.userRepository.GetUserProfileByUserID(ctx, user.KeycloakUserID) + if err != nil { + logger.Warn(constants.LogRetrieveUserProfileFailed, "error", err, "userID", user.KeycloakUserID) + userProfile = nil // Continue without profile if not found + } + + // Get address if profile has address ID + var address *models.AddressDB + if userProfile != nil && userProfile.AddressID != nil { + address, _ = s.userRepository.GetAddressByID(ctx, *userProfile.AddressID) + } + + // Create user response + userResponse := &models.UserResponse{} + userResponse.CreateUserResponse(user, userProfile) + + // Fetch and populate zone data + zoneData, err := s.GetZoneMappingsByUserID(ctx, user.KeycloakUserID) + if err != nil { + logger.Warn(constants.LogFetchZoneMappingsFailed, "error", err, "userID", user.KeycloakUserID) + } else { + userResponse.ZoneData = zoneData + } + + // Set address if available + if address != nil { + userResponse.Profile.Address = &models.Address{ + AddressLine1: address.AddressLine1, + AddressLine2: address.AddressLine2, + City: address.City, + State: address.State, + PinCode: address.PinCode, + } + } + + userResponses = append(userResponses, userResponse) + } + + logger.Info(constants.LogGetUsersByRoleSuccess, "role", role, "returnedCount", len(userResponses)) + return userResponses, nil +} + +// DeleteUserByKeycloakID deletes a user from the system using their Keycloak user ID. +// +// Parameters: +// - keycloakUserID: The unique identifier of the user in Keycloak. +// +// Returns: +// - An error if the operation fails, or nil if the user is successfully deleted. +// +// Description: +// +// This method validates the provided Keycloak user ID and attempts to delete the user from the repository. +// If the deletion is successful, an informational log is recorded. If the operation fails, an error is logged +// and returned to the caller. +func (s *UserService) DeleteUserByKeycloakID(keycloakUserID string) error { + // Validate input + if keycloakUserID == "" { + return fmt.Errorf(constants.ErrKeycloakUserIDEmpty) + } + + logger.Info(constants.ErrDeleteUser, "keycloakUserID", keycloakUserID) + + err := s.keycloakService.DeleteUser(keycloakUserID) + if err != nil { + logger.Error(constants.ErrDeleteUser, "error", err, "keycloakUserID", keycloakUserID) + return fmt.Errorf("%s: %w", constants.ErrDeleteUser, err) + } + + // Delete user using repository + ctx := context.Background() + err = s.userRepository.Delete(ctx, keycloakUserID) + if err != nil { + logger.Error("Failed to delete user", "error", err, "keycloakUserID", keycloakUserID) + return err + } + + logger.Info("User deleted successfully", "keycloakUserID", keycloakUserID) + return nil +} + +// UpdateUserComplete updates user and profile information, including their preferred language. +// +// Parameters: +// - keycloakUserID: The unique identifier of the user in Keycloak. +// - updateReq: A pointer to the UpdateUserRequest object containing the updated user details. +// +// Returns: +// - A UserResponse object containing the updated user's details. +// - An error if the operation fails. +// +// Description: +// +// This method validates the provided Keycloak user ID and checks if the user exists. It updates the user's +// basic information, including their preferred language, and their profile details in the repository. After +// updating, it retrieves the updated user data and returns it. If any operation fails, an appropriate error +// is logged and returned. +func (s *UserService) UpdateUserComplete(keycloakUserID string, updateReq *models.UpdateUserRequest) (*models.UserResponse, error) { + // Validate input + if keycloakUserID == "" { + return nil, errors.NewServiceError(constants.ErrKeycloakUserIDEmpty) + } + + // Check if user exists + ctx := context.Background() + _, err := s.userRepository.GetByKeycloakUserID(ctx, keycloakUserID) + if err != nil { + logger.Error(constants.ErrUserNotFound, "error", err, "keycloakUserID", keycloakUserID) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrUserNotFound, err)) + } + + logger.Info(constants.LogUpdateUserStart, "keycloakUserID", keycloakUserID, "email", updateReq.Email, "preferredLanguage", updateReq.PreferredLanguage) + + // Update user basic info (including preferred language) + err = s.userRepository.UpdateUser(ctx, keycloakUserID, updateReq) + if err != nil { + logger.Error(constants.ErrUpdateUserFailed, "error", err, "keycloakUserID", keycloakUserID) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrUpdateUserFailed, err)) + } + + // Update user profile + err = s.userRepository.UpdateUserProfileComplete(ctx, keycloakUserID, &updateReq.Profile) + if err != nil { + logger.Error(constants.ErrUpdateUserProfileFailed, "error", err, "keycloakUserID", keycloakUserID) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrUpdateUserProfileFailed, err)) + } + + // Get updated user data + updatedUser, err := s.GetUser(ctx, keycloakUserID) + if err != nil { + logger.Error(constants.ErrGetUpdatedUserFailed, "error", err, "keycloakUserID", keycloakUserID) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrGetUpdatedUserFailed, err)) + } + + logger.Info(constants.LogUpdateUserSuccess, "keycloakUserID", keycloakUserID) + return updatedUser, nil +} + +// GetAllUsersWithFilters retrieves users based on optional filters and pagination parameters. +// +// Parameters: +// - ctx: Context for managing request-scoped values. +// - filters: A UserFilters object containing the filtering criteria. +// - limit: The maximum number of users to retrieve. +// - offset: The starting point for pagination. +// +// Returns: +// - A UsersListResponse object containing the list of users and pagination details. +// - An error if the operation fails. +// +// Description: +// +// This method fetches users from the repository based on the provided filters and pagination parameters. +// For each user, it retrieves the user profile, address (if available), and zone mappings. The retrieved +// data is used to populate a UserResponse object for each user. If no users are found or an error occurs, +// appropriate errors are logged and returned. +func (s *UserService) GetAllUsersWithFilters(ctx context.Context, filters models.UserFilters, limit, offset int) (*models.UsersListResponse, error) { + logger.Info(constants.LogGetUsersWithFiltersStart, "limit", limit, "offset", offset) + + // Get users with filters from repository + users, totalCount, err := s.userRepository.GetAllUsersWithFilters(ctx, filters, limit, offset) + if err != nil { + logger.Error(constants.ErrGetUsersWithFilters, "error", err) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrGetUsersWithFilters, err)) + } + + // Check if no users were found + if len(users) == 0 { + logger.Error(constants.ErrNoUsersFound) + return nil, errors.NewServiceError(constants.ErrNoUsersFound) + } + + // Convert to UserResponse format + var userResponses []*models.UserResponse + for _, user := range users { + keycloakUser, err := s.keycloakService.GetUserByID(user.KeycloakUserID) + if err != nil { + logger.Warn(constants.ErrFetchKeycloakUser, "error", err, "userID", user.KeycloakUserID) + } + + if keycloakUser == nil || !keycloakUser.Enabled { + logger.Warn("Skipping disabled user in Keycloak", "userID", user.KeycloakUserID) + continue // Skip disabled users + } + // Get user profile + userProfile, err := s.userRepository.GetUserProfileByUserID(ctx, user.KeycloakUserID) + if err != nil { + logger.Warn(constants.LogRetrieveUserProfileFailed, "error", err, "userID", user.KeycloakUserID) + userProfile = nil // Continue without profile if not found + } + + // Create user response + userResponse := &models.UserResponse{} + userResponse.CreateUserResponse(user, userProfile) + + // Fetch and populate address if AddressID exists + if userProfile != nil && userProfile.AddressID != nil { + s.populateAddress(ctx, userResponse, *userProfile.AddressID) + } + + // Fetch and populate zone data + if zoneData, err := s.GetZoneMappingsByUserID(ctx, user.KeycloakUserID); err != nil { + logger.Warn(constants.LogFetchZoneMappingsFailed, "error", err, "userID", user.KeycloakUserID) + } else { + userResponse.ZoneData = zoneData + } + + userResponses = append(userResponses, userResponse) + } + + response := &models.UsersListResponse{ + Users: userResponses, + TotalCount: int(totalCount), + Limit: limit, + Offset: offset, + } + + logger.Info(constants.LogUsersRetrieved, "totalCount", totalCount, "returnedCount", len(userResponses)) + return response, nil +} + +// InsertZoneMappings inserts zone mappings for a user into the repository. +// +// Parameters: +// - ctx: Context for managing request-scoped values. +// - userID: The unique identifier of the user for whom the zone mappings are being inserted. +// - zoneData: A slice of ZoneMapping objects containing the zone and ward details to be inserted. +// +// Returns: +// - An error if the operation fails, or nil if the zone mappings are successfully inserted. +// +// Description: +// +// This method iterates over the provided zoneData slice and creates a ZoneMapping object for each entry. +// It then inserts the ZoneMapping into the repository. If any insertion fails, an error is returned +// immediately. If all insertions succeed, the method returns nil. +func (s *UserService) InsertZoneMappings(ctx context.Context, userID string, zoneData []models.ZoneMapping) error { + for _, zone := range zoneData { + mapping := models.ZoneMapping{ + UserID: userID, + Zone: zone.Zone, + Wards: pq.StringArray(zone.Wards), + } + if err := s.zoneMappingRepository.CreateZoneMapping(ctx, &mapping); err != nil { + return fmt.Errorf("failed to insert zone mapping for user %s, zone %s: %w", userID, zone.Zone, err) + } + } + return nil +} + +// ValidateZoneAndWards validates the provided zone and wards against the MDMS data. +// +// Parameters: +// - zone: The zone to validate. +// - wards: A slice of ward names to validate within the zone. +// +// Returns: +// - A slice of allowed wards if the validation is successful. +// - An error if the validation fails. +// +// Description: +// This method fetches zone details from the MDMS service and validates the provided zone and wards. +// If the zone is invalid or any of the wards are not allowed within the zone, an error is returned. +// The method logs the validation process, including any errors encountered. +func (s *UserService) ValidateZoneAndWards(zone string, wards []string) ([]string, error) { + logger.Info(constants.LogZoneValidationStart, "zone", zone, "wards", wards) + + mdmsResponse, err := utils.GetZoneDetailsFromMDMS(zone) + if err != nil { + logger.Error(constants.ErrFetchZoneDetailsMDMS, "error", err, "zone", zone) + if strings.Contains(err.Error(), "status: 400") { + return nil, errors.NewServiceError(fmt.Sprintf("%s: %s", constants.ErrInvalidZone, zone)) + } + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrFetchZoneDetailsMDMS, err)) + } + + if len(mdmsResponse.MDMS) == 0 || mdmsResponse.MDMS[0].Data.Zone != zone { + logger.Error(constants.ErrZoneNotFound, "zone", zone) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %s", constants.ErrZoneNotFound, zone)) + } + + allowedWards := mdmsResponse.MDMS[0].Data.Wards + for _, ward := range wards { + if !contains(allowedWards, ward) { + logger.Error(constants.ErrWardNotAllowed, "ward", ward, "zone", zone, "allowedWards", allowedWards) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %s. Allowed: %v", constants.ErrWardNotAllowed, ward, allowedWards)) + } + } + + logger.Info(constants.LogZoneValidationSuccess, "zone", zone, "wards", wards) + return allowedWards, nil +} + +// GetZoneMappingsByUserID retrieves zone mappings for a user. +// +// Parameters: +// - ctx: Context for managing request-scoped values. +// - userID: The unique identifier of the user whose zone mappings are to be retrieved. +// +// Returns: +// - A slice of ZoneData objects containing the zone and ward details. +// - An error if the operation fails. +// +// Description: +// This method fetches zone mappings for the specified user from the repository. It converts the retrieved +// ZoneMapping objects into ZoneData objects, which include the zone number and a slice of ward names. +// If an error occurs during the fetch operation, it is logged and returned. +func (s *UserService) GetZoneMappingsByUserID(ctx context.Context, userID string) ([]models.ZoneData, error) { + zoneMappings, err := s.zoneMappingRepository.GetZoneMappingsByUser(ctx, userID) + if err != nil { + logger.Error(constants.ErrFetchZoneMappings, "error", err, "userID", userID) + return nil, errors.NewServiceError(fmt.Sprintf("%s: %v", constants.ErrFetchZoneMappings, err)) + } + + // Convert ZoneMapping to ZoneData + zoneData := make([]models.ZoneData, len(zoneMappings)) + for i, mapping := range zoneMappings { + zoneData[i] = models.ZoneData{ + ZoneNumber: mapping.Zone, + Wards: []string(mapping.Wards), // Convert pq.StringArray to []string + } + } + + return zoneData, nil +} + +// contains checks if a slice contains a specific item. +// +// Parameters: +// - slice: The slice of strings to search within. +// - item: The string item to search for. +// +// Returns: +// - A boolean indicating whether the item is found in the slice. +func contains(slice []string, item string) bool { + for _, v := range slice { + if v == item { + return true + } + } + return false +} + +// GetUserCounts retrieves counts for total users, active users, and field agents. +func (s *UserService) GetUserCounts(ctx context.Context) (map[string]int64, error) { + logger.Info("Getting user counts") + + totalUsers, activeUsers, fieldAgents, err := s.userRepository.GetUserCounts(ctx) + if err != nil { + logger.Error("Failed to get user counts", "error", err) + return nil, err + } + + counts := map[string]int64{ + "totalUsers": totalUsers, + "activeUsers": activeUsers, + "fieldAgents": fieldAgents, + } + + logger.Info("User counts retrieved successfully", "counts", counts) + return counts, nil +} diff --git a/backend/onboarding/internal/utils/keycloak_utils.go b/backend/onboarding/internal/utils/keycloak_utils.go new file mode 100644 index 0000000..cdaedb7 --- /dev/null +++ b/backend/onboarding/internal/utils/keycloak_utils.go @@ -0,0 +1,22 @@ +// Package utils provides utility functions for Keycloak and other integrations. +// This file contains helpers for extracting and converting Keycloak attributes. +package utils + +// ExtractStringAttribute extracts the first string value for a given key from Keycloak attributes. +// Returns the string if found, or an empty string if not present or not a string. + +// Parameters: +// - attributes: Keycloak attributes map (string to interface{}) +// - key: The attribute key to extract +// Returns: +// - string: The first string value for the key, or "" if not found +func ExtractStringAttribute(attributes map[string]interface{}, key string) string { + if attr, exists := attributes[key]; exists { + if attrSlice, ok := attr.([]interface{}); ok && len(attrSlice) > 0 { + if str, ok := attrSlice[0].(string); ok { + return str + } + } + } + return "" +} diff --git a/backend/onboarding/internal/utils/mdms_utils.go b/backend/onboarding/internal/utils/mdms_utils.go new file mode 100644 index 0000000..1d8c28e --- /dev/null +++ b/backend/onboarding/internal/utils/mdms_utils.go @@ -0,0 +1,77 @@ +package utils + +import ( + "encoding/json" + "fmt" + "net/http" + "property-tax-onboarding/internal/config" + "property-tax-onboarding/internal/constants" + "property-tax-onboarding/pkg/logger" +) + +type MDMSResponse struct { + MDMS []struct { + ID string `json:"id"` + TenantID string `json:"tenantId"` + SchemaCode string `json:"schemaCode"` + UniqueIdentifier string `json:"uniqueIdentifier"` + Data struct { + Zone string `json:"zone"` + Wards []string `json:"wards"` + } `json:"data"` + IsActive bool `json:"isActive"` + } `json:"mdms"` +} + +// GetZoneDetailsFromMDMS fetches zone details from the MDMS (Master Data Management System) API. +// +// Parameters: +// - zone: A string representing the zone for which details are to be fetched. +// +// Returns: +// - A pointer to an MDMSResponse object containing the zone details if the operation is successful. +// - An error if the operation fails, such as issues with the HTTP request, response decoding, or non-200 status codes. +// +// Description: +// This method constructs an HTTP GET request to the MDMS API using the provided zone and the base URL +// fetched from the application configuration. It sets the necessary headers for the request, including +// content type, tenant ID, and client ID. The method sends the request using an HTTP client and processes +// the response. If the response status code is not 200 (OK), or if there is an error in decoding the response +// body, the method logs the error and returns it to the caller. +func GetZoneDetailsFromMDMS(zone string) (*MDMSResponse, error) { + // Fetch the MDMS API URL from the configuration + url := fmt.Sprintf("%s%s", config.GetConfig().MDMS_API_URL, zone) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + logger.Error(constants.ErrCreateMDMSRequest, "error", err) + return nil, err + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Tenant-ID", "pb.amritsar") + req.Header.Set("X-Client-Id", "test-client") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + logger.Error(constants.ErrCallMDMSAPI, "error", err) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.Error(constants.ErrMDMSAPINon200Status, "status", resp.StatusCode) + return nil, fmt.Errorf("MDMS API returned status: %d", resp.StatusCode) + } + + var mdmsResponse MDMSResponse + if err := json.NewDecoder(resp.Body).Decode(&mdmsResponse); err != nil { + logger.Error(constants.ErrDecodeMDMSResponse, "error", err) + return nil, err + } + + return &mdmsResponse, nil +} diff --git a/backend/onboarding/internal/validator/user_validation_service.go b/backend/onboarding/internal/validator/user_validation_service.go new file mode 100644 index 0000000..8b3ee9b --- /dev/null +++ b/backend/onboarding/internal/validator/user_validation_service.go @@ -0,0 +1,151 @@ +// Package validator provides validation logic for user-related API requests. +// This file implements validation for user creation, including uniqueness checks and field validation. +package validator + +import ( + "fmt" + "property-tax-onboarding/internal/constants" + "property-tax-onboarding/internal/errors" + "property-tax-onboarding/internal/models" + "property-tax-onboarding/internal/repositories" + "property-tax-onboarding/pkg/logger" + "regexp" + "strconv" + "strings" + "time" + "gorm.io/gorm" +) + +// UserValidationService provides validation logic for user creation and update requests. +// Integrates with the user repository and DB to check for uniqueness and data integrity. +type UserValidationService struct { + userRepository repositories.UserRepository // Used for repository-based checks + db *gorm.DB // GORM DB instance for direct queries +} + +// NewUserValidationService creates and returns a new UserValidationService instance. +// Injects the user repository and GORM DB for duplicate checks. +func NewUserValidationService(userRepo repositories.UserRepository, db *gorm.DB) *UserValidationService { + return &UserValidationService{ + userRepository: userRepo, + db: db, + } +} + +// ValidateCreateUserRequest validates the CreateUserRequest payload for user creation. +// Checks for required fields, format, and uniqueness of Aadhaar and phone number. +// Returns an error if validation fails, or nil if valid. +func (v *UserValidationService) ValidateCreateUserRequest(req *models.CreateUserRequest) error { + // Basic validation for all users + if len(strings.TrimSpace(req.Username)) < 3 { + return fmt.Errorf(constants.ErrInvalidUsername) + } + + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + if !emailRegex.MatchString(req.Email) { + return fmt.Errorf(constants.ErrInvalidEmail) + } + + if len(strings.TrimSpace(req.Profile.FirstName)) < 2 { + return fmt.Errorf(constants.ErrInvalidFirstName) + } + + if req.Role != models.RoleCitizen { + if req.Password == "" { + return fmt.Errorf(constants.ErrPasswordRequiredForNonCitizens) + } + if len(req.Password) < 4 { + return fmt.Errorf(constants.ErrInvalidPassword) + } + } + + if len(strings.TrimSpace(req.Profile.PhoneNumber)) != 10 { + return fmt.Errorf(constants.ErrInvalidPhoneNumber) + } + + // Validate additional profile fields + if len(strings.TrimSpace(req.Profile.FullName)) < 2 { + return fmt.Errorf(constants.ErrInvalidFullName) + } + + // Aadhaar validation - ONLY validate if provided and not nil + if req.Profile.AdhaarNo != nil && *req.Profile.AdhaarNo > 0 { + adhaarStr := strconv.FormatInt(*req.Profile.AdhaarNo, 10) + if len(adhaarStr) != 12 { + return fmt.Errorf("adhaar number must be exactly 12 digits") + } + + // Check for duplicate Aadhaar + exists, err := v.CheckAdhaarExists(*req.Profile.AdhaarNo) + if err != nil { + return fmt.Errorf("%s: %w", constants.ErrCheckAdhaarNumberFailed, err) + } + if exists { + return fmt.Errorf(constants.ErrDuplicateAdhaarNumber) + } + } + + if req.Profile.OwnershipShare < 0 || req.Profile.OwnershipShare > 100 { + return fmt.Errorf(constants.ErrInvalidOwnershipShare) + } + + // Validate role using switch for better readability + switch req.Role { + case models.RoleAgent, models.RoleCitizen, models.RoleServiceManager, models.RoleCommissioner, models.RoleAdmin: + // Valid role + default: + return fmt.Errorf(constants.ErrUnsupportedRole) + } + + // Check for duplicate phone number + exists, err := v.CheckPhoneExists(req.Profile.PhoneNumber) + if err != nil { + return fmt.Errorf("%s: %w", constants.ErrCheckPhoneNumberFailed, err) + } + if exists { + return fmt.Errorf(constants.ErrDuplicatePhoneNumber) + } + + if req.StartDate != nil && req.EndDate != nil { + if req.EndDate.Before(*req.StartDate) { + return fmt.Errorf(constants.ErrInvalidDateRange) + } + if req.StartDate.Before(time.Now()) { + return fmt.Errorf(constants.ErrStartDateInPast) + } + } + + return nil +} + +// CheckAdhaarExists checks if an Aadhaar number already exists in the user profiles table. +// Returns true if the Aadhaar exists, or false otherwise. Logs and wraps errors for traceability. +func (v *UserValidationService) CheckAdhaarExists(adhaarNo int64) (bool, error) { + logger.Info(constants.LogCheckAdhaarExistsStart, "adhaarNo", adhaarNo) + + var count int64 + if err := v.db.Model(&models.UserProfile{}).Where("adhaar_no = ?", adhaarNo).Count(&count).Error; err != nil { + logger.Error(constants.ErrCheckAdhaarExistsFailed, "error", err, "adhaarNo", adhaarNo) + return false, errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrCheckAdhaarExistsFailed, err)) + } + + exists := count > 0 + logger.Info(constants.LogCheckAdhaarExistsSuccess, "adhaarNo", adhaarNo, "exists", exists) + return exists, nil +} + +// CheckPhoneExists checks if a phone number already exists in the user profiles table. +// Returns true if the phone number exists, or false otherwise. Logs and wraps errors for traceability. +func (v *UserValidationService) CheckPhoneExists(phoneNumber string) (bool, error) { + logger.Info(constants.LogCheckPhoneExistsStart, "phoneNumber", phoneNumber) + + var count int64 + if err := v.db.Model(&models.UserProfile{}).Where("phone_number = ?", phoneNumber).Count(&count).Error; err != nil { + logger.Error(constants.ErrCheckPhoneExistsFailed, "error", err, "phoneNumber", phoneNumber) + return false, errors.NewRepositoryError(fmt.Sprintf("%s: %v", constants.ErrCheckPhoneExistsFailed, err)) + } + + exists := count > 0 + logger.Info(constants.LogCheckPhoneExistsSuccess, "phoneNumber", phoneNumber, "exists", exists) + return exists, nil +} diff --git a/backend/onboarding/migrations/address.sql b/backend/onboarding/migrations/address.sql new file mode 100644 index 0000000..7979da7 --- /dev/null +++ b/backend/onboarding/migrations/address.sql @@ -0,0 +1,10 @@ +-- Create the address table +CREATE TABLE address ( + id UUID NOT NULL DEFAULT gen_random_uuid(), -- Unique identifier for the address + address_line1 VARCHAR(200) NOT NULL, -- First line of the address + address_line2 VARCHAR(200), -- Second line of the address (nullable) + city VARCHAR(100) NOT NULL, -- City (not nullable) + state VARCHAR(100) NOT NULL, -- State (not nullable) + pin_code VARCHAR(10) NOT NULL, -- Postal code (not nullable) + PRIMARY KEY (id) -- Primary key on the `id` column +); \ No newline at end of file diff --git a/backend/onboarding/migrations/user_profiles.sql b/backend/onboarding/migrations/user_profiles.sql new file mode 100644 index 0000000..cf439e0 --- /dev/null +++ b/backend/onboarding/migrations/user_profiles.sql @@ -0,0 +1,27 @@ +-- Create the userprofile table +CREATE TABLE user_profiles ( + user_profile_id VARCHAR(255) NOT NULL, -- References keycloak_user_id in the users table + first_name VARCHAR(100) NOT NULL, -- First name of the user + last_name VARCHAR(100) NOT NULL, -- Last name of the user + full_name VARCHAR(200) NOT NULL, -- Full name of the user + phone_number VARCHAR(15) NOT NULL, -- Phone number of the user + adhaar_no BIGINT , -- Aadhaar number (unique constraint) + gender VARCHAR(10) , -- Gender of the user + guardian VARCHAR(100), -- Guardian's name (nullable) + guardian_type VARCHAR(50), -- Type of guardian (nullable) + date_of_birth DATE, -- Date of birth (nullable) + department VARCHAR(100), -- Department (nullable) + designation VARCHAR(100), -- Designation (nullable) + work_location VARCHAR(200), -- Work location (nullable) + profile_picture TEXT, -- Profile picture URL or data (nullable) + relationship_to_property VARCHAR(50) NOT NULL DEFAULT '', -- Relationship to the property + ownership_share DOUBLE PRECISION NOT NULL DEFAULT 0 -- Ownership share (0-100%) + is_primary_owner BOOLEAN DEFAULT FALSE, -- Indicates if the user is the primary owner + is_verified BOOLEAN DEFAULT FALSE, -- Indicates if the user is verified + address_id UUID, -- Foreign key to the address table (nullable) + PRIMARY KEY (user_profile_id), -- Primary key on the `user_profile_id` column + CONSTRAINT userprofile_adhaar_no_key UNIQUE (adhaar_no), -- Unique constraint on Aadhaar number + CONSTRAINT userprofile_id_fkey FOREIGN KEY (user_profile_id) REFERENCES users(keycloak_user_id) ON DELETE CASCADE, -- Foreign key to the users table + CONSTRAINT userprofile_address_id_fkey FOREIGN KEY (address_id) REFERENCES address(id) ON DELETE CASCADE -- Foreign key to the address table + CONSTRAINT userprofile_ownership_share_check CHECK (ownership_share >= 0 AND ownership_share <= 100) -- Checking the Ownership Constraints +); \ No newline at end of file diff --git a/backend/onboarding/migrations/users.sql b/backend/onboarding/migrations/users.sql new file mode 100644 index 0000000..174b7a5 --- /dev/null +++ b/backend/onboarding/migrations/users.sql @@ -0,0 +1,18 @@ +CREATE TABLE users ( + keycloak_user_id VARCHAR(255) NOT NULL PRIMARY KEY, -- Unique identifier for the user + username VARCHAR(255) NOT NULL UNIQUE, -- Username must be unique + email VARCHAR(255) NOT NULL UNIQUE, -- Email must be unique + role VARCHAR(50) NOT NULL, -- Role of the user (e.g., AGENT, CITIZEN, etc.) + is_active BOOLEAN NOT NULL DEFAULT TRUE, -- Indicates if the user is active + preferred_language VARCHAR(50), -- Preferred language of the user (nullable) + created_at TIMESTAMP NOT NULL DEFAULT NOW(), -- Timestamp when the record was created + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), -- Timestamp when the record was last updated + created_by VARCHAR(255), -- User who created the record (nullable) + updated_by VARCHAR(255), -- User who last updated the record (nullable) + start_date DATE, -- start date of the user + end_date DATE -- end_date of the user + CONSTRAINT users_pkey PRIMARY KEY (keycloak_user_id), -- primary key + CONSTRAINT users_email_key UNIQUE (email), -- email set as a unique + CONSTRAINT users_username_key UNIQUE (username) -- username set as a unique +); +CREATE INDEX IF NOT EXISTS idx_users_deleted ON users (deleted); --indexing for deleted \ No newline at end of file diff --git a/backend/onboarding/migrations/zone_mapping.sql b/backend/onboarding/migrations/zone_mapping.sql new file mode 100644 index 0000000..a007c12 --- /dev/null +++ b/backend/onboarding/migrations/zone_mapping.sql @@ -0,0 +1,20 @@ +CREATE TABLE DIGIT3.zone_mapping ( + user_id VARCHAR(255) NOT NULL, + zone VARCHAR(50) NOT NULL, + ward TEXT[] NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now(), + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now(), + CONSTRAINT zone_mapping_pkey PRIMARY KEY (user_id, zone) +); + +-- 2. Create indexes +CREATE INDEX idx_zone_mapping_user ON DIGIT3.zone_mapping(user_id); +CREATE INDEX idx_zone_mapping_zone ON DIGIT3.zone_mapping(zone); +CREATE INDEX idx_zone_mapping_ward_gin ON DIGIT3.zone_mapping USING gin(ward); + +-- 3. Add foreign key constraint with ON DELETE CASCADE +ALTER TABLE DIGIT3.zone_mapping +ADD CONSTRAINT zone_mapping_user_id_fkey +FOREIGN KEY (user_id) +REFERENCES users(keycloak_user_id) +ON DELETE CASCADE; \ No newline at end of file diff --git a/backend/onboarding/pkg/logger/logger.go b/backend/onboarding/pkg/logger/logger.go new file mode 100644 index 0000000..21c6e56 --- /dev/null +++ b/backend/onboarding/pkg/logger/logger.go @@ -0,0 +1,108 @@ +// Package logger provides simple logging utilities for the onboarding service. +// This file implements leveled logging (info, warn, error, fatal) with key-value support. +package logger + +import ( + "fmt" + "log" + "os" +) + +var ( + infoLogger *log.Logger + errorLogger *log.Logger + warnLogger *log.Logger + fatalLogger *log.Logger +) + +// InitLogger initializes all loggers with appropriate prefixes and output streams. +func InitLogger() { + infoLogger = log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile) + errorLogger = log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile) + warnLogger = log.New(os.Stdout, "WARN: ", log.Ldate|log.Ltime|log.Lshortfile) + fatalLogger = log.New(os.Stderr, "FATAL: ", log.Ldate|log.Ltime|log.Lshortfile) +} + +// Info logs informational messages with optional key-value pairs. +// Initializes the logger if not already set. +func Info(message string, keyvals ...interface{}) { + if infoLogger == nil { + InitLogger() + } + + logMessage := message + for i := 0; i < len(keyvals); i += 2 { + if i+1 < len(keyvals) { + logMessage += " " + keyvals[i].(string) + "=" + toString(keyvals[i+1]) + } + } + infoLogger.Println(logMessage) +} + +// Error logs error messages with optional key-value pairs. +// Initializes the logger if not already set. +func Error(message string, keyvals ...interface{}) { + if errorLogger == nil { + InitLogger() + } + + logMessage := message + + if len(keyvals) > 0 { + if err, ok := keyvals[0].(error); ok { + logMessage += " " + err.Error() + keyvals = keyvals[1:] // Remove the error from keyvals + } + } + for i := 0; i < len(keyvals); i += 2 { + if i+1 < len(keyvals) { + logMessage += " " + keyvals[i].(string) + "=" + toString(keyvals[i+1]) + } + } + errorLogger.Println(logMessage) +} + +// Warn logs warning messages with optional key-value pairs. +// Initializes the logger if not already set. +func Warn(message string, keyvals ...interface{}) { + if warnLogger == nil { + InitLogger() + } + + logMessage := message + for i := 0; i < len(keyvals); i += 2 { + if i+1 < len(keyvals) { + logMessage += " " + keyvals[i].(string) + "=" + toString(keyvals[i+1]) + } + } + warnLogger.Println(logMessage) +} + +// Fatal logs fatal messages and exits the application +func Fatal(message string, keyvals ...interface{}) { + if fatalLogger == nil { + InitLogger() + } + + logMessage := message + for i := 0; i < len(keyvals); i += 2 { + if i+1 < len(keyvals) { + logMessage += " " + keyvals[i].(string) + "=" + toString(keyvals[i+1]) + } + } + fatalLogger.Println(logMessage) + os.Exit(1) // Terminate the application +} + +// toString converts an interface{} value to a string for logging. +// Supports string and error types; returns an empty string for others. +func toString(v interface{}) string { + switch s := v.(type) { + case string: + return s + case error: + return s.Error() + default: + return fmt.Sprintf("%v", v) + } +} diff --git a/backend/onboarding/pkg/response/response.go b/backend/onboarding/pkg/response/response.go new file mode 100644 index 0000000..e114723 --- /dev/null +++ b/backend/onboarding/pkg/response/response.go @@ -0,0 +1,38 @@ +// Package response provides standardized API response helpers for the onboarding service. +// This file defines the APIResponse struct and utility functions for success and error responses. +package response + +import ( + "github.com/gin-gonic/gin" +) + +// APIResponse represents a standard API response structure for all endpoints. +// Includes success flag, message, data payload, and error details. +type APIResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +// Success sends a successful JSON response with the given status code, message, and data. +// Used by handlers to return standardized success responses. +func Success(c *gin.Context, statusCode int, message string, data interface{}) { + response := APIResponse{ + Success: true, + Message: message, + Data: data, + } + c.JSON(statusCode, response) +} + +// Error sends a standardized error JSON response with the given status code, message, and error detail. +// Used by handlers to return error responses in a consistent format. +func Error(c *gin.Context, statusCode int, message string, errorDetail string) { + response := APIResponse{ + Success: false, + Message: message, + Errors: []string{errorDetail}, + } + c.JSON(statusCode, response) +} diff --git a/backend/onboarding/postman/new_onboarding.postman_collection.json b/backend/onboarding/postman/new_onboarding.postman_collection.json new file mode 100644 index 0000000..4c43401 --- /dev/null +++ b/backend/onboarding/postman/new_onboarding.postman_collection.json @@ -0,0 +1,349 @@ +{ + "info": { + "_postman_id": "postman-id", + "name": "new_onboarding", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "exporter-id", + "_collection_link": "https://www.postman.com/collections/your-collection-link" + }, + "item": [ + { + "name": "create user", + "request": { + "method": "POST", + "header": [ + { + "key": "", + "value": "", + "type": "default" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"username\": \"\",\r\n \"email\": \"\",\r\n \"password\": \"\",\r\n \"role\": \"\",\r\n \"zonedata\": [\n {\n \"zoneNumber\": \"\",\n \"wards\": [\"\", \"\"]\n }\n ],\r\n \"preferred_language\": \"\",\r\n \"createdBy\": \"\",\r\n \"updatedBy\": \"\",\r\n \"profile\": {\r\n \"firstName\": \"\",\r\n \"lastName\": \"\",\r\n \"fullName\": \"\",\r\n \"phoneNumber\": \"\",\r\n \"adhaarNo\": ,\r\n \"gender\": \"\",\r\n \"relationshipToProperty\": \"\",\r\n \"ownershipShare\": ,\r\n \"isPrimaryOwner\": \r\n }\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8089/api/v1/users", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8089", + "path": [ + "api", + "v1", + "users" + ] + } + }, + "response": [] + }, + { + "name": "Get by user id", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8089/api/v1/users/{userId}", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8089", + "path": [ + "api", + "v1", + "users", + "{userId}" + ] + } + }, + "response": [] + }, + { + "name": "Get user by role", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8089/api/v1/users/by-role/CITIZEN", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8089", + "path": [ + "api", + "v1", + "users", + "by-role", + "CITIZEN" + ] + } + }, + "response": [] + }, + { + "name": "delete by user id", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "http://localhost:8089/api/v1/users/{userId}", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8089", + "path": [ + "api", + "v1", + "users", + "{userId}" + ] + } + }, + "response": [] + }, + { + "name": "update user by id", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"username\": \"\",\r\n \"email\": \"\",\r\n \"role\": \"\",\r\n \"isActive\": ,\r\n \"ward\": \"\",\r\n \"preferred_language\": \"\",\r\n \"createdBy\": \"\",\r\n \"updatedBy\":\"\",\r\n \"profile\": {\r\n \"firstName\": \"\",\r\n \"lastName\": \"\",\r\n \"fullName\": \"\",\r\n \"phoneNumber\": \"\",\r\n \"adhaarNo\": ,\r\n \"gender\": \"\",\r\n \"guardian\": \"\",\r\n \"guardianType\": \"\",\r\n \"dateOfBirth\": \"\",\r\n \"address\": {\r\n \"address_line1\": \"\",\r\n \"address_line2\": \"\",\r\n \"city\": \"\",\r\n \"state\": \"\",\r\n \"pin_code\": \"\"\r\n },\r\n \"department\": \"\",\r\n \"designation\": \"\",\r\n \"workLocation\": \"\",\r\n \"profilePicture\": \"\",\r\n \"relationshipToProperty\": \"\",\r\n \"ownershipShare\": ,\r\n \"isPrimaryOwner\": ,\r\n \"isVerified\": \r\n }\r\n }", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8089/api/v1/users/{userId}", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8089", + "path": [ + "api", + "v1", + "users", + "{userId}" + ] + } + }, + "response": [] + }, + { + "name": "GET All User", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8089/api/v1/users", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8089", + "path": [ + "api", + "v1", + "users" + ] + } + }, + "response": [] + }, + { + "name": "GET all users (Filter by role)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8089/api/v1/users?role=CITIZEN", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8089", + "path": [ + "api", + "v1", + "users" + ], + "query": [ + { + "key": "role", + "value": "CITIZEN" + } + ] + } + }, + "response": [] + }, + { + "name": "GET All users (Filter by isActive)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8089/api/v1/users?isActive=true", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8089", + "path": [ + "api", + "v1", + "users" + ], + "query": [ + { + "key": "isActive", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "Partial username search", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8089/api/v1/users?username={username}&role=CITIZEN", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8089", + "path": [ + "api", + "v1", + "users" + ], + "query": [ + { + "key": "username", + "value": "{username}" + }, + { + "key": "role", + "value": "CITIZEN" + } + ] + } + }, + "response": [] + }, + { + "name": "partial email", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8089/api/v1/users?email=@example.com", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8089", + "path": [ + "api", + "v1", + "users" + ], + "query": [ + { + "key": "email", + "value": "@example.com" + } + ] + } + }, + "response": [] + }, + { + "name": "filter by ward", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8089/api/v1/users?ward=Ward-49", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8089", + "path": [ + "api", + "v1", + "users" + ], + "query": [ + { + "key": "ward", + "value": "Ward-49" + } + ] + } + }, + "response": [] + }, + { + "name": "Multiple filters with pagination", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8089/api/v1/users?role=CITIZEN&isActive=true&limit=20&offset=0", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8089", + "path": [ + "api", + "v1", + "users" + ], + "query": [ + { + "key": "role", + "value": "CITIZEN" + }, + { + "key": "isActive", + "value": "true" + }, + { + "key": "ward", + "value": "Ward-15", + "disabled": true + }, + { + "key": "limit", + "value": "20" + }, + { + "key": "offset", + "value": "0" + } + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file