This commit is contained in:
2025-08-31 22:35:23 -04:00
parent ac51f75b5c
commit 1430c97ae7
36 changed files with 9962 additions and 73 deletions

View File

@ -1,73 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/RyanCopley/skybridge/faas/internal/domain"
"github.com/RyanCopley/skybridge/faas/internal/runtime/docker"
"go.uber.org/zap"
)
func main() {
// Create a logger
logger, err := zap.NewDevelopment()
if err != nil {
log.Fatal("Failed to create logger:", err)
}
defer logger.Sync()
// Create the Docker runtime
runtime, err := docker.NewSimpleDockerRuntime(logger)
if err != nil {
log.Fatal("Failed to create Docker runtime:", err)
}
// Test health check
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := runtime.HealthCheck(ctx); err != nil {
log.Fatal("Docker runtime health check failed:", err)
}
fmt.Println("Docker runtime health check passed!")
// Get runtime info
info, err := runtime.GetInfo(ctx)
if err != nil {
log.Fatal("Failed to get runtime info:", err)
}
fmt.Printf("Runtime Info: %+v\n", info)
// Test with a simple function (using a basic image)
function := &domain.FunctionDefinition{
Name: "test-function",
Image: "alpine:latest",
}
// Deploy the function (pull the image)
fmt.Println("Deploying function...")
if err := runtime.Deploy(ctx, function); err != nil {
log.Fatal("Failed to deploy function:", err)
}
fmt.Println("Function deployed successfully!")
// Test execution with a simple command
input := json.RawMessage(`{"cmd": "echo Hello World"}`)
fmt.Println("Executing function...")
result, err := runtime.Execute(ctx, function, input)
if err != nil {
log.Fatal("Failed to execute function:", err)
}
fmt.Printf("Execution result: %+v\n", result)
fmt.Println("Logs:", result.Logs)
fmt.Println("Output:", string(result.Output))
}

271
user/CLAUDE.md Normal file
View File

@ -0,0 +1,271 @@
# CLAUDE.md - User Management Service
This file provides guidance to Claude Code (claude.ai/code) when working with the User Management Service.
## Project Overview
The User Management Service is part of the Skybridge platform, providing comprehensive user management capabilities including CRUD operations, role management, and status tracking. Built with Go backend and React micro-frontend.
**Key Technologies:**
- **Backend**: Go 1.23+ with Gin router, PostgreSQL, JWT tokens
- **Frontend**: React 18+ with TypeScript, Mantine UI components
- **Module Federation**: Webpack 5 Module Federation for plugin architecture
- **Infrastructure**: Podman/Docker Compose, Nginx
- **Security**: Header-based auth (dev) / JWT (prod), RBAC permissions
## Development Commands
### Go Backend Development
```bash
# Build the service
go build -o user-service ./cmd/server
# Run with environment variables
DB_HOST=localhost DB_NAME=users DB_USER=postgres DB_PASSWORD=postgres go run cmd/server/main.go
# Run tests
go test -v ./test/...
# Tidy modules
go mod tidy
```
### React Frontend Development
```bash
# Navigate to web directory
cd web
# Install dependencies
npm install
# Start development server on port 3004
npm run dev
# Build for production
npm run build
```
### Docker Development (Recommended)
**CRITICAL**: This service uses `docker-compose` (not podman-compose like KMS).
```bash
# Start all services (PostgreSQL + User Service)
docker-compose up -d
# Start with forced rebuild after code changes
docker-compose up -d --build
# View service logs
docker-compose logs -f user-service
docker-compose logs -f postgres
# Check service health
curl http://localhost:8090/health
# Stop services
docker-compose down
```
### Full Platform Integration
```bash
# Start the main shell dashboard
cd ../../web
npm install
npm run dev # Available at http://localhost:3000
# Start the user service frontend
cd ../user/web
npm run dev # Available at http://localhost:3004
# The user management module loads automatically at http://localhost:3000/app/user
```
## Database Operations
**CRITICAL**: All database operations use `docker exec` commands with container name `user-postgres`.
```bash
# Access database shell
docker exec -it user-postgres psql -U postgres -d users
# Run SQL commands
docker exec -it user-postgres psql -U postgres -c "SELECT * FROM users LIMIT 5;"
# Check tables
docker exec -it user-postgres psql -U postgres -d users -c "\dt"
# Apply migrations (handled automatically on startup)
docker exec -it user-postgres psql -U postgres -d users -f /docker-entrypoint-initdb.d/001_initial_schema.up.sql
```
## Key Architecture Patterns
### Backend Patterns
- **Repository Pattern**: Data access via interfaces (`internal/repository/interfaces/`)
- **Service Layer**: Business logic in `internal/services/`
- **Clean Architecture**: Separation of concerns with domain models
- **Middleware Chain**: CORS, auth, logging, recovery
- **Structured Logging**: Zap logger with JSON output
### Frontend Patterns
- **Micro-frontend**: Module Federation integration with shell dashboard
- **Mantine UI**: Consistent component library across platform
- **TypeScript**: Strong typing for all data models
- **Service Layer**: API client with axios and interceptors
### Integration with Skybridge Platform
- **Port Allocation**: API:8090, Frontend:3004
- **Module Federation**: Automatic registration in microfrontends.js
- **Navigation Integration**: Appears as "User Management" in shell
- **Shared Dependencies**: React, Mantine, icons shared across modules
## Important Configuration
### Required Environment Variables
```bash
# Database (Required)
DB_HOST=localhost # Use 'postgres' for containers
DB_PORT=5432
DB_NAME=users
DB_USER=postgres
DB_PASSWORD=postgres
DB_SSLMODE=disable
# Server
SERVER_HOST=0.0.0.0
SERVER_PORT=8090
# Authentication
AUTH_PROVIDER=header # Use 'header' for development
AUTH_HEADER_USER_EMAIL=X-User-Email
```
### API Endpoints
```bash
# Health checks
GET /health
GET /ready
# User management
GET /api/users # List with filters
POST /api/users # Create user
GET /api/users/:id # Get by ID
PUT /api/users/:id # Update user
DELETE /api/users/:id # Delete user
GET /api/users/email/:email # Get by email
GET /api/users/exists/:email # Check existence
# Documentation
GET /api/docs # API documentation
```
## User Model Structure
### Core User Fields
- `id` (UUID): Primary key
- `email` (string): Unique email address
- `first_name`, `last_name` (string): Required name fields
- `display_name` (string): Optional display name
- `avatar` (string): Optional avatar URL
- `role` (enum): admin, moderator, user, viewer
- `status` (enum): active, inactive, suspended, pending
- `created_at`, `updated_at` (timestamp)
- `created_by`, `updated_by` (string): Audit fields
### Related Models
- `UserProfile`: Extended profile information
- `UserSession`: Session tracking
- `AuditEvent`: Operation logging
## Testing
### API Testing with curl
```bash
# Check health
curl http://localhost:8090/health
# Create user (requires X-User-Email header)
curl -X POST http://localhost:8090/api/users \
-H "Content-Type: application/json" \
-H "X-User-Email: admin@example.com" \
-d '{
"email": "test@example.com",
"first_name": "Test",
"last_name": "User",
"role": "user",
"status": "active"
}'
# List users
curl -H "X-User-Email: admin@example.com" \
"http://localhost:8090/api/users?limit=10&status=active"
```
### Frontend Testing
- Access http://localhost:3000/app/user in the shell dashboard
- Test create, edit, delete, search, and filter functionality
- Verify Module Federation loading and navigation
## Go Client Library Usage
```go
import "github.com/RyanCopley/skybridge/user/client"
// Create client
userClient := client.NewUserClient("http://localhost:8090", "admin@example.com")
// Create user
user, err := userClient.CreateUser(&domain.CreateUserRequest{
Email: "test@example.com",
FirstName: "Test",
LastName: "User",
Role: "user",
})
```
## Build and Deployment
### Local Development
1. Start PostgreSQL: `docker-compose up -d postgres`
2. Run backend: `go run cmd/server/main.go`
3. Start frontend: `cd web && npm run dev`
4. Access via shell dashboard: http://localhost:3000/app/user
### Production Deployment
1. Build backend: `go build -o user-service ./cmd/server`
2. Build frontend: `cd web && npm run build`
3. Use Docker: `docker-compose up -d`
## Important Notes
### Development Workflow
- Database migrations run automatically on startup
- Use header-based authentication with `X-User-Email: admin@example.com`
- Frontend hot reloads on port 3004
- Module Federation integrates automatically with shell dashboard
### Container Details
- **user-postgres**: PostgreSQL on port 5433 (external), 5432 (internal)
- **user-service**: API service on port 8090
- **Frontend**: Development server on port 3004
### Integration Points
- Registered in `/web/src/microfrontends.js` as 'user'
- Navigation icon: IconUsers from Tabler Icons
- Route: `/app/user` in shell dashboard
- Category: "Administration" in navigation
### Common Issues
- Database connection: Ensure postgres container is running
- Module Federation: Frontend must be running on port 3004
- Authentication: Include X-User-Email header in all API requests
- CORS: All origins allowed in development
This service follows the same patterns as the KMS service but focuses on user management functionality with a clean, modern UI integrated into the Skybridge platform.

48
user/Dockerfile Normal file
View File

@ -0,0 +1,48 @@
# Multi-stage build for minimal image
FROM docker.io/library/golang:1.23-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git ca-certificates wget
# Create non-root user for building
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Set working directory
WORKDIR /app
# Copy go.mod first for better caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build static binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags='-w -s -extldflags "-static"' \
-a -installsuffix cgo \
-o user-service \
./cmd/server
# Final stage: minimal runtime image
FROM docker.io/library/alpine:3.18
# Install runtime dependencies
RUN apk --no-cache add ca-certificates wget tzdata
# Create non-root user for running the app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy binary from builder
COPY --from=builder /app/user-service .
# Use non-root user
USER appuser
# Expose port
EXPOSE 8090
# Run the app
CMD ["./user-service"]

248
user/README.md Normal file
View File

@ -0,0 +1,248 @@
# User Management Service
A comprehensive user management microservice built with Go backend and React frontend, designed to integrate with the Skybridge platform architecture.
## Features
- **Full CRUD Operations**: Create, read, update, and delete users
- **Role-Based Access**: Admin, moderator, user, and viewer roles
- **User Status Management**: Active, inactive, suspended, and pending states
- **Advanced Search & Filtering**: Search by name/email, filter by role/status
- **Pagination Support**: Handle large user datasets efficiently
- **Micro-frontend Architecture**: Seamlessly integrates with the shell dashboard
- **RESTful API**: Clean, well-documented API endpoints
- **Database Migrations**: Automated schema management
- **Health Checks**: Service health and readiness endpoints
- **Audit Logging**: Track all user management operations
## Architecture
### Backend (Go)
- **Framework**: Gin HTTP framework
- **Database**: PostgreSQL with sqlx
- **Architecture**: Clean architecture with repositories and services
- **Authentication**: Header-based (development) / JWT (production)
- **Logging**: Structured logging with Zap
### Frontend (React)
- **Framework**: React 18+ with TypeScript
- **UI Library**: Mantine v7
- **Module Federation**: Webpack 5 Module Federation
- **State Management**: React hooks and context
- **HTTP Client**: Axios with interceptors
## Quick Start
### Using Docker Compose (Recommended)
```bash
# Start all services
docker-compose up -d
# Check service health
curl http://localhost:8090/health
# View logs
docker-compose logs -f user-service
# Stop services
docker-compose down
```
### Development Setup
#### Backend
```bash
# Navigate to user service directory
cd user
# Install dependencies
go mod download
# Set environment variables
export DB_HOST=localhost
export DB_NAME=users
export DB_USER=postgres
export DB_PASSWORD=postgres
# Run the service
go run cmd/server/main.go
```
#### Frontend
```bash
# Navigate to web directory
cd user/web
# Install dependencies
npm install
# Start development server
npm run dev
# Frontend will be available at http://localhost:3004
```
#### Full Platform Integration
```bash
# Start the shell dashboard
cd web
npm install
npm run dev # Available at http://localhost:3000
# The user management module will load automatically at /app/user
```
## API Endpoints
### User Management
- `GET /api/users` - List users with filters
- `POST /api/users` - Create new user
- `GET /api/users/:id` - Get user by ID
- `PUT /api/users/:id` - Update user
- `DELETE /api/users/:id` - Delete user
- `GET /api/users/email/:email` - Get user by email
- `GET /api/users/exists/:email` - Check if user exists
### Health & Status
- `GET /health` - Service health check
- `GET /ready` - Service readiness check
- `GET /api/docs` - API documentation
## Environment Variables
### Required
- `DB_HOST` - Database host (default: localhost)
- `DB_PORT` - Database port (default: 5432)
- `DB_NAME` - Database name (default: users)
- `DB_USER` - Database username
- `DB_PASSWORD` - Database password
### Optional
- `SERVER_HOST` - Server host (default: 0.0.0.0)
- `SERVER_PORT` - Server port (default: 8090)
- `APP_ENV` - Environment (development/production)
- `LOG_LEVEL` - Logging level (debug/info/warn/error)
- `AUTH_PROVIDER` - Authentication provider (header/jwt)
- `AUTH_HEADER_USER_EMAIL` - Auth header name (default: X-User-Email)
## Database Schema
### Users Table
```sql
- id (UUID, primary key)
- email (VARCHAR, unique)
- first_name, last_name (VARCHAR)
- display_name (VARCHAR, optional)
- avatar (VARCHAR, optional)
- role (ENUM: admin, moderator, user, viewer)
- status (ENUM: active, inactive, suspended, pending)
- last_login_at (TIMESTAMP, optional)
- created_at, updated_at (TIMESTAMP)
- created_by, updated_by (VARCHAR)
```
### Additional Tables
- `user_profiles` - Extended user profile information
- `user_sessions` - Session management
- `audit_events` - Audit logging
## Go Client Library
```go
package main
import (
"github.com/RyanCopley/skybridge/user/client"
"github.com/RyanCopley/skybridge/user/internal/domain"
)
func main() {
// Create client
userClient := client.NewUserClient("http://localhost:8090", "admin@example.com")
// Create user
createReq := &domain.CreateUserRequest{
Email: "john@example.com",
FirstName: "John",
LastName: "Doe",
Role: "user",
Status: "active",
}
user, err := userClient.CreateUser(createReq)
if err != nil {
panic(err)
}
// List users
listReq := &domain.ListUsersRequest{
Status: &domain.UserStatusActive,
Limit: 10,
}
response, err := userClient.ListUsers(listReq)
if err != nil {
panic(err)
}
}
```
## Frontend Integration
The user management frontend automatically integrates with the Skybridge shell dashboard through Module Federation. It appears in the navigation under "User Management" and is accessible at `/app/user`.
### Key Components
- `UserManagement` - Main list view with search/filter
- `UserForm` - Create/edit user form
- `userService` - API client service
- Type definitions for all data models
## Development
### Running Tests
```bash
# Backend tests
go test -v ./test/...
# Frontend tests
cd user/web
npm test
```
### Building
```bash
# Backend binary
go build -o user-service ./cmd/server
# Frontend production build
cd user/web
npm run build
```
## Deployment
### Docker
```bash
# Build image
docker build -t user-service .
# Run container
docker run -p 8090:8090 \
-e DB_HOST=postgres \
-e DB_PASSWORD=postgres \
user-service
```
### Integration with Skybridge Platform
1. The user service runs on port 8090 (configurable)
2. The frontend runs on port 3004 during development
3. Module Federation automatically makes it available in the shell dashboard
4. Authentication is handled via header forwarding from the main platform
## Contributing
1. Follow the established patterns from the KMS service
2. Maintain clean architecture separation
3. Update tests for new functionality
4. Ensure frontend follows Mantine design patterns
5. Test Module Federation integration

270
user/client/client.go Normal file
View File

@ -0,0 +1,270 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/google/uuid"
"github.com/RyanCopley/skybridge/user/internal/domain"
)
// UserClient provides an interface to interact with the user service
type UserClient struct {
baseURL string
httpClient *http.Client
userEmail string // For authentication
}
// NewUserClient creates a new user service client
func NewUserClient(baseURL, userEmail string) *UserClient {
return &UserClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
userEmail: userEmail,
}
}
// NewUserClientWithTimeout creates a new user service client with custom timeout
func NewUserClientWithTimeout(baseURL, userEmail string, timeout time.Duration) *UserClient {
return &UserClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: timeout,
},
userEmail: userEmail,
}
}
// CreateUser creates a new user
func (c *UserClient) CreateUser(req *domain.CreateUserRequest) (*domain.User, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
httpReq, err := c.newRequest("POST", "/api/users", bytes.NewBuffer(body))
if err != nil {
return nil, err
}
var user domain.User
err = c.doRequest(httpReq, &user)
if err != nil {
return nil, err
}
return &user, nil
}
// GetUserByID retrieves a user by ID
func (c *UserClient) GetUserByID(id uuid.UUID) (*domain.User, error) {
path := fmt.Sprintf("/api/users/%s", id.String())
httpReq, err := c.newRequest("GET", path, nil)
if err != nil {
return nil, err
}
var user domain.User
err = c.doRequest(httpReq, &user)
if err != nil {
return nil, err
}
return &user, nil
}
// GetUserByEmail retrieves a user by email
func (c *UserClient) GetUserByEmail(email string) (*domain.User, error) {
path := fmt.Sprintf("/api/users/email/%s", url.PathEscape(email))
httpReq, err := c.newRequest("GET", path, nil)
if err != nil {
return nil, err
}
var user domain.User
err = c.doRequest(httpReq, &user)
if err != nil {
return nil, err
}
return &user, nil
}
// UpdateUser updates an existing user
func (c *UserClient) UpdateUser(id uuid.UUID, req *domain.UpdateUserRequest) (*domain.User, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
path := fmt.Sprintf("/api/users/%s", id.String())
httpReq, err := c.newRequest("PUT", path, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
var user domain.User
err = c.doRequest(httpReq, &user)
if err != nil {
return nil, err
}
return &user, nil
}
// DeleteUser deletes a user by ID
func (c *UserClient) DeleteUser(id uuid.UUID) error {
path := fmt.Sprintf("/api/users/%s", id.String())
httpReq, err := c.newRequest("DELETE", path, nil)
if err != nil {
return err
}
return c.doRequest(httpReq, nil)
}
// ListUsers retrieves a list of users with optional filters
func (c *UserClient) ListUsers(req *domain.ListUsersRequest) (*domain.ListUsersResponse, error) {
// Build query parameters
params := url.Values{}
if req.Status != nil {
params.Set("status", string(*req.Status))
}
if req.Role != nil {
params.Set("role", string(*req.Role))
}
if req.Search != "" {
params.Set("search", req.Search)
}
if req.Limit > 0 {
params.Set("limit", strconv.Itoa(req.Limit))
}
if req.Offset > 0 {
params.Set("offset", strconv.Itoa(req.Offset))
}
if req.OrderBy != "" {
params.Set("order_by", req.OrderBy)
}
if req.OrderDir != "" {
params.Set("order_dir", req.OrderDir)
}
path := "/api/users"
if len(params) > 0 {
path += "?" + params.Encode()
}
httpReq, err := c.newRequest("GET", path, nil)
if err != nil {
return nil, err
}
var response domain.ListUsersResponse
err = c.doRequest(httpReq, &response)
if err != nil {
return nil, err
}
return &response, nil
}
// ExistsByEmail checks if a user exists with the given email
func (c *UserClient) ExistsByEmail(email string) (bool, error) {
path := fmt.Sprintf("/api/users/exists/%s", url.PathEscape(email))
httpReq, err := c.newRequest("GET", path, nil)
if err != nil {
return false, err
}
var response map[string]interface{}
err = c.doRequest(httpReq, &response)
if err != nil {
return false, err
}
exists, ok := response["exists"].(bool)
if !ok {
return false, fmt.Errorf("invalid response format")
}
return exists, nil
}
// Health checks the health of the user service
func (c *UserClient) Health() (map[string]interface{}, error) {
httpReq, err := c.newRequest("GET", "/health", nil)
if err != nil {
return nil, err
}
var response map[string]interface{}
err = c.doRequest(httpReq, &response)
if err != nil {
return nil, err
}
return response, nil
}
// newRequest creates a new HTTP request with authentication headers
func (c *UserClient) newRequest(method, path string, body io.Reader) (*http.Request, error) {
url := c.baseURL + path
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
// Add authentication header
if c.userEmail != "" {
req.Header.Set("X-User-Email", c.userEmail)
}
return req, nil
}
// doRequest executes an HTTP request and handles the response
func (c *UserClient) doRequest(req *http.Request, target interface{}) error {
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode >= 400 {
var errorResponse map[string]interface{}
if json.Unmarshal(body, &errorResponse) == nil {
if errorMsg, ok := errorResponse["error"].(string); ok {
return fmt.Errorf("API error (status %d): %s", resp.StatusCode, errorMsg)
}
}
return fmt.Errorf("HTTP error (status %d): %s", resp.StatusCode, string(body))
}
// If target is nil, we don't need to unmarshal (e.g., for DELETE requests)
if target == nil {
return nil
}
if err := json.Unmarshal(body, target); err != nil {
return fmt.Errorf("failed to unmarshal response: %w", err)
}
return nil
}

182
user/cmd/server/main.go Normal file
View File

@ -0,0 +1,182 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/RyanCopley/skybridge/user/internal/config"
"github.com/RyanCopley/skybridge/user/internal/database"
"github.com/RyanCopley/skybridge/user/internal/handlers"
"github.com/RyanCopley/skybridge/user/internal/middleware"
"github.com/RyanCopley/skybridge/user/internal/repository/postgres"
"github.com/RyanCopley/skybridge/user/internal/services"
)
func main() {
// Initialize configuration
cfg := config.NewConfig()
if err := cfg.Validate(); err != nil {
log.Fatal("Configuration validation failed:", err)
}
// Initialize logger
logger := initLogger(cfg)
defer logger.Sync()
logger.Info("Starting User Management Service",
zap.String("version", cfg.GetString("APP_VERSION")),
zap.String("environment", cfg.GetString("APP_ENV")),
)
// Initialize database
logger.Info("Connecting to database",
zap.String("dsn", cfg.GetDatabaseDSNForLogging()))
db, err := database.NewPostgresProvider(
cfg.GetDatabaseDSN(),
cfg.GetInt("DB_MAX_OPEN_CONNS"),
cfg.GetInt("DB_MAX_IDLE_CONNS"),
cfg.GetString("DB_CONN_MAX_LIFETIME"),
)
if err != nil {
logger.Fatal("Failed to initialize database",
zap.String("dsn", cfg.GetDatabaseDSNForLogging()),
zap.Error(err))
}
logger.Info("Database connection established successfully")
// Initialize repositories
userRepo := postgres.NewUserRepository(db)
profileRepo := postgres.NewUserProfileRepository(db)
// Initialize services
userService := services.NewUserService(userRepo, profileRepo, nil, logger)
// Initialize handlers
healthHandler := handlers.NewHealthHandler(db, logger)
userHandler := handlers.NewUserHandler(userService, logger)
// Set up router
router := setupRouter(cfg, logger, healthHandler, userHandler)
// Create HTTP server
srv := &http.Server{
Addr: cfg.GetServerAddress(),
Handler: router,
ReadTimeout: cfg.GetDuration("SERVER_READ_TIMEOUT"),
WriteTimeout: cfg.GetDuration("SERVER_WRITE_TIMEOUT"),
IdleTimeout: cfg.GetDuration("SERVER_IDLE_TIMEOUT"),
}
// Start server in goroutine
go func() {
logger.Info("Starting HTTP server", zap.String("address", srv.Addr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal("Failed to start server", zap.Error(err))
}
}()
// Wait for interrupt signal to gracefully shutdown the server
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("Shutting down server...")
// Give outstanding requests time to complete
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Shutdown server
if err := srv.Shutdown(ctx); err != nil {
logger.Error("Server forced to shutdown", zap.Error(err))
}
logger.Info("Server exited")
}
func initLogger(cfg config.ConfigProvider) *zap.Logger {
var logger *zap.Logger
var err error
if cfg.IsProduction() {
logger, err = zap.NewProduction()
} else {
logger, err = zap.NewDevelopment()
}
if err != nil {
log.Fatal("Failed to initialize logger:", err)
}
return logger
}
func setupRouter(cfg config.ConfigProvider, logger *zap.Logger, healthHandler *handlers.HealthHandler, userHandler *handlers.UserHandler) *gin.Engine {
// Set Gin mode based on environment
if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
// Add middleware
router.Use(middleware.Logger(logger))
router.Use(middleware.Recovery(logger))
router.Use(middleware.CORS())
// Health check endpoints (no authentication required)
router.GET("/health", healthHandler.Health)
router.GET("/ready", healthHandler.Ready)
// API routes
api := router.Group("/api")
{
// Protected routes (require authentication)
protected := api.Group("/")
protected.Use(middleware.Authentication(cfg, logger))
{
// User management
protected.GET("/users", userHandler.List)
protected.POST("/users", userHandler.Create)
protected.GET("/users/:id", userHandler.GetByID)
protected.PUT("/users/:id", userHandler.Update)
protected.DELETE("/users/:id", userHandler.Delete)
// User lookup endpoints
protected.GET("/users/email/:email", userHandler.GetByEmail)
protected.GET("/users/exists/:email", userHandler.ExistsByEmail)
// Documentation endpoint
protected.GET("/docs", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"service": "User Management Service",
"version": cfg.GetString("APP_VERSION"),
"documentation": "User service API endpoints",
"endpoints": map[string]interface{}{
"users": []string{
"GET /api/users",
"POST /api/users",
"GET /api/users/:id",
"PUT /api/users/:id",
"DELETE /api/users/:id",
"GET /api/users/email/:email",
"GET /api/users/exists/:email",
},
},
})
})
}
}
return router
}

60
user/docker-compose.yml Normal file
View File

@ -0,0 +1,60 @@
version: '3.8'
services:
user-postgres:
image: docker.io/library/postgres:15-alpine
container_name: user-postgres
environment:
POSTGRES_DB: users
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5433:5432"
volumes:
- user_postgres_data:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d:Z
networks:
- user-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d users"]
interval: 10s
timeout: 5s
retries: 5
user-service:
build:
context: .
dockerfile: Dockerfile
container_name: user-service
depends_on:
user-postgres:
condition: service_healthy
environment:
DB_HOST: user-postgres
DB_PORT: 5432
DB_NAME: users
DB_USER: postgres
DB_PASSWORD: postgres
DB_SSLMODE: disable
SERVER_HOST: 0.0.0.0
SERVER_PORT: 8090
APP_ENV: development
LOG_LEVEL: debug
AUTH_PROVIDER: header
AUTH_HEADER_USER_EMAIL: X-User-Email
ports:
- "8090:8090"
networks:
- user-network
# healthcheck:
# test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8090/health"]
# interval: 30s
# timeout: 5s
# retries: 3
restart: unless-stopped
networks:
user-network:
volumes:
user_postgres_data:

42
user/go.mod Normal file
View File

@ -0,0 +1,42 @@
module github.com/RyanCopley/skybridge/user
go 1.23
require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
go.uber.org/zap v1.27.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // 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.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

109
user/go.sum Normal file
View File

@ -0,0 +1,109 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -0,0 +1,166 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
// ConfigProvider defines the interface for configuration operations
type ConfigProvider interface {
GetString(key string) string
GetInt(key string) int
GetBool(key string) bool
GetDuration(key string) time.Duration
IsSet(key string) bool
Validate() error
GetDatabaseDSN() string
GetDatabaseDSNForLogging() string
GetServerAddress() string
IsProduction() bool
}
// Config implements the ConfigProvider interface
type Config struct {
defaults map[string]string
}
// NewConfig creates a new configuration instance
func NewConfig() ConfigProvider {
// Load .env file if it exists
_ = godotenv.Load()
return &Config{
defaults: map[string]string{
"SERVER_HOST": "0.0.0.0",
"SERVER_PORT": "8090",
"SERVER_READ_TIMEOUT": "30s",
"SERVER_WRITE_TIMEOUT": "30s",
"SERVER_IDLE_TIMEOUT": "60s",
"DB_HOST": "localhost",
"DB_PORT": "5432",
"DB_NAME": "users",
"DB_USER": "postgres",
"DB_PASSWORD": "postgres",
"DB_SSLMODE": "disable",
"DB_MAX_OPEN_CONNS": "25",
"DB_MAX_IDLE_CONNS": "5",
"DB_CONN_MAX_LIFETIME": "5m",
"APP_ENV": "development",
"APP_VERSION": "1.0.0",
"LOG_LEVEL": "debug",
"RATE_LIMIT_ENABLED": "true",
"RATE_LIMIT_RPS": "100",
"RATE_LIMIT_BURST": "200",
"AUTH_PROVIDER": "header",
"AUTH_HEADER_USER_EMAIL": "X-User-Email",
},
}
}
func (c *Config) GetString(key string) string {
if value := os.Getenv(key); value != "" {
return value
}
return c.defaults[key]
}
func (c *Config) GetInt(key string) int {
value := c.GetString(key)
if value == "" {
return 0
}
intVal, err := strconv.Atoi(value)
if err != nil {
return 0
}
return intVal
}
func (c *Config) GetBool(key string) bool {
value := strings.ToLower(c.GetString(key))
return value == "true" || value == "1"
}
func (c *Config) GetDuration(key string) time.Duration {
value := c.GetString(key)
if value == "" {
return 0
}
duration, err := time.ParseDuration(value)
if err != nil {
return 0
}
return duration
}
func (c *Config) IsSet(key string) bool {
return os.Getenv(key) != "" || c.defaults[key] != ""
}
func (c *Config) Validate() error {
required := []string{
"SERVER_HOST",
"SERVER_PORT",
"DB_HOST",
"DB_PORT",
"DB_NAME",
"DB_USER",
"DB_PASSWORD",
}
var missing []string
for _, key := range required {
if c.GetString(key) == "" {
missing = append(missing, key)
}
}
if len(missing) > 0 {
return fmt.Errorf("missing required configuration keys: %s", strings.Join(missing, ", "))
}
return nil
}
func (c *Config) GetDatabaseDSN() string {
host := c.GetString("DB_HOST")
port := c.GetString("DB_PORT")
user := c.GetString("DB_USER")
password := c.GetString("DB_PASSWORD")
dbname := c.GetString("DB_NAME")
sslmode := c.GetString("DB_SSLMODE")
// Debug logging to see what values we're getting
// fmt.Printf("DEBUG DSN VALUES: host=%s port=%s user=%s password=%s dbname=%s sslmode=%s\n",
// host, port, user, password, dbname, sslmode)
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
host, port, user, password, dbname, sslmode)
return dsn
}
func (c *Config) GetDatabaseDSNForLogging() string {
return fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=%s",
c.GetString("DB_HOST"),
c.GetString("DB_PORT"),
c.GetString("DB_USER"),
c.GetString("DB_NAME"),
c.GetString("DB_SSLMODE"),
)
}
func (c *Config) GetServerAddress() string {
return fmt.Sprintf("%s:%s", c.GetString("SERVER_HOST"), c.GetString("SERVER_PORT"))
}
func (c *Config) IsProduction() bool {
return strings.ToLower(c.GetString("APP_ENV")) == "production"
}

View File

@ -0,0 +1,34 @@
package database
import (
"fmt"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq" // PostgreSQL driver
)
// NewPostgresProvider creates a new PostgreSQL database connection
func NewPostgresProvider(dsn string, maxOpenConns, maxIdleConns int, maxLifetime string) (*sqlx.DB, error) {
db, err := sqlx.Connect("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
// Set connection pool settings
db.SetMaxOpenConns(maxOpenConns)
db.SetMaxIdleConns(maxIdleConns)
if maxLifetime != "" {
if lifetime, err := time.ParseDuration(maxLifetime); err == nil {
db.SetConnMaxLifetime(lifetime)
}
}
// Test connection
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return db, nil
}

View File

@ -0,0 +1,130 @@
package domain
import (
"time"
"github.com/google/uuid"
)
// UserStatus represents the status of a user account
type UserStatus string
const (
UserStatusActive UserStatus = "active"
UserStatusInactive UserStatus = "inactive"
UserStatusSuspended UserStatus = "suspended"
UserStatusPending UserStatus = "pending"
)
// UserRole represents the role of a user
type UserRole string
const (
UserRoleAdmin UserRole = "admin"
UserRoleUser UserRole = "user"
UserRoleModerator UserRole = "moderator"
UserRoleViewer UserRole = "viewer"
)
// User represents a user in the system
type User struct {
ID uuid.UUID `json:"id" db:"id"`
Email string `json:"email" validate:"required,email,max=255" db:"email"`
FirstName string `json:"first_name" validate:"required,min=1,max=100" db:"first_name"`
LastName string `json:"last_name" validate:"required,min=1,max=100" db:"last_name"`
DisplayName string `json:"display_name" validate:"omitempty,max=200" db:"display_name"`
Avatar string `json:"avatar,omitempty" validate:"omitempty,url,max=500" db:"avatar"`
Role UserRole `json:"role" validate:"required,oneof=admin user moderator viewer" db:"role"`
Status UserStatus `json:"status" validate:"required,oneof=active inactive suspended pending" db:"status"`
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
CreatedBy string `json:"created_by" validate:"required" db:"created_by"`
UpdatedBy string `json:"updated_by" validate:"required" db:"updated_by"`
}
// UserProfile represents extended user profile information
type UserProfile struct {
UserID uuid.UUID `json:"user_id" db:"user_id"`
Bio string `json:"bio,omitempty" validate:"omitempty,max=1000" db:"bio"`
Location string `json:"location,omitempty" validate:"omitempty,max=200" db:"location"`
Website string `json:"website,omitempty" validate:"omitempty,url,max=500" db:"website"`
Timezone string `json:"timezone,omitempty" validate:"omitempty,max=50" db:"timezone"`
Language string `json:"language,omitempty" validate:"omitempty,max=10" db:"language"`
Preferences map[string]interface{} `json:"preferences,omitempty" db:"preferences"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// UserSession represents a user session
type UserSession struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" validate:"required" db:"user_id"`
Token string `json:"-" db:"token"` // Hidden from JSON
IPAddress string `json:"ip_address" validate:"required" db:"ip_address"`
UserAgent string `json:"user_agent" validate:"required" db:"user_agent"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
LastUsedAt time.Time `json:"last_used_at" db:"last_used_at"`
}
// CreateUserRequest represents a request to create a new user
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email,max=255"`
FirstName string `json:"first_name" validate:"required,min=1,max=100"`
LastName string `json:"last_name" validate:"required,min=1,max=100"`
DisplayName string `json:"display_name,omitempty" validate:"omitempty,max=200"`
Avatar string `json:"avatar,omitempty" validate:"omitempty,url,max=500"`
Role UserRole `json:"role" validate:"required,oneof=admin user moderator viewer"`
Status UserStatus `json:"status" validate:"omitempty,oneof=active inactive suspended pending"`
}
// UpdateUserRequest represents a request to update an existing user
type UpdateUserRequest struct {
Email *string `json:"email,omitempty" validate:"omitempty,email,max=255"`
FirstName *string `json:"first_name,omitempty" validate:"omitempty,min=1,max=100"`
LastName *string `json:"last_name,omitempty" validate:"omitempty,min=1,max=100"`
DisplayName *string `json:"display_name,omitempty" validate:"omitempty,max=200"`
Avatar *string `json:"avatar,omitempty" validate:"omitempty,url,max=500"`
Role *UserRole `json:"role,omitempty" validate:"omitempty,oneof=admin user moderator viewer"`
Status *UserStatus `json:"status,omitempty" validate:"omitempty,oneof=active inactive suspended pending"`
}
// UpdateUserProfileRequest represents a request to update user profile
type UpdateUserProfileRequest struct {
Bio *string `json:"bio,omitempty" validate:"omitempty,max=1000"`
Location *string `json:"location,omitempty" validate:"omitempty,max=200"`
Website *string `json:"website,omitempty" validate:"omitempty,url,max=500"`
Timezone *string `json:"timezone,omitempty" validate:"omitempty,max=50"`
Language *string `json:"language,omitempty" validate:"omitempty,max=10"`
Preferences *map[string]interface{} `json:"preferences,omitempty"`
}
// ListUsersRequest represents a request to list users with filters
type ListUsersRequest struct {
Status *UserStatus `json:"status,omitempty" validate:"omitempty,oneof=active inactive suspended pending"`
Role *UserRole `json:"role,omitempty" validate:"omitempty,oneof=admin user moderator viewer"`
Search string `json:"search,omitempty" validate:"omitempty,max=255"`
Limit int `json:"limit,omitempty" validate:"omitempty,min=1,max=100"`
Offset int `json:"offset,omitempty" validate:"omitempty,min=0"`
OrderBy string `json:"order_by,omitempty" validate:"omitempty,oneof=created_at updated_at email first_name last_name"`
OrderDir string `json:"order_dir,omitempty" validate:"omitempty,oneof=asc desc"`
}
// ListUsersResponse represents a response for listing users
type ListUsersResponse struct {
Users []User `json:"users"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
HasMore bool `json:"has_more"`
}
// AuthContext represents the authentication context for a request
type AuthContext struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role UserRole `json:"role"`
Permissions []string `json:"permissions"`
Claims map[string]string `json:"claims"`
}

View File

@ -0,0 +1,52 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
)
// HealthHandler handles health check requests
type HealthHandler struct {
db *sqlx.DB
logger *zap.Logger
}
// NewHealthHandler creates a new health handler
func NewHealthHandler(db *sqlx.DB, logger *zap.Logger) *HealthHandler {
return &HealthHandler{
db: db,
logger: logger,
}
}
// Health handles GET /health
func (h *HealthHandler) Health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": "user-service",
"version": "1.0.0",
})
}
// Ready handles GET /ready
func (h *HealthHandler) Ready(c *gin.Context) {
// Check database connection
if err := h.db.Ping(); err != nil {
h.logger.Error("Database health check failed", zap.Error(err))
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "not ready",
"reason": "database connection failed",
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ready",
"service": "user-service",
"database": "connected",
})
}

View File

@ -0,0 +1,280 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/RyanCopley/skybridge/user/internal/domain"
"github.com/RyanCopley/skybridge/user/internal/services"
)
// UserHandler handles HTTP requests for user operations
type UserHandler struct {
userService services.UserService
logger *zap.Logger
}
// NewUserHandler creates a new user handler
func NewUserHandler(userService services.UserService, logger *zap.Logger) *UserHandler {
return &UserHandler{
userService: userService,
logger: logger,
}
}
// Create handles POST /users
func (h *UserHandler) Create(c *gin.Context) {
var req domain.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Error("Invalid request body", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid request body",
"details": err.Error(),
})
return
}
// Get actor from context (set by auth middleware)
actorID := getActorFromContext(c)
user, err := h.userService.Create(c.Request.Context(), &req, actorID)
if err != nil {
h.logger.Error("Failed to create user", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to create user",
"details": err.Error(),
})
return
}
c.JSON(http.StatusCreated, user)
}
// GetByID handles GET /users/:id
func (h *UserHandler) GetByID(c *gin.Context) {
idParam := c.Param("id")
id, err := uuid.Parse(idParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid user ID",
"details": "User ID must be a valid UUID",
})
return
}
user, err := h.userService.GetByID(c.Request.Context(), id)
if err != nil {
if err.Error() == "user not found" {
c.JSON(http.StatusNotFound, gin.H{
"error": "User not found",
})
return
}
h.logger.Error("Failed to get user", zap.String("id", id.String()), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get user",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, user)
}
// Update handles PUT /users/:id
func (h *UserHandler) Update(c *gin.Context) {
idParam := c.Param("id")
id, err := uuid.Parse(idParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid user ID",
"details": "User ID must be a valid UUID",
})
return
}
var req domain.UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Error("Invalid request body", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid request body",
"details": err.Error(),
})
return
}
// Get actor from context (set by auth middleware)
actorID := getActorFromContext(c)
user, err := h.userService.Update(c.Request.Context(), id, &req, actorID)
if err != nil {
if err.Error() == "user not found" {
c.JSON(http.StatusNotFound, gin.H{
"error": "User not found",
})
return
}
h.logger.Error("Failed to update user", zap.String("id", id.String()), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to update user",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, user)
}
// Delete handles DELETE /users/:id
func (h *UserHandler) Delete(c *gin.Context) {
idParam := c.Param("id")
id, err := uuid.Parse(idParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid user ID",
"details": "User ID must be a valid UUID",
})
return
}
// Get actor from context (set by auth middleware)
actorID := getActorFromContext(c)
err = h.userService.Delete(c.Request.Context(), id, actorID)
if err != nil {
if err.Error() == "user not found" {
c.JSON(http.StatusNotFound, gin.H{
"error": "User not found",
})
return
}
h.logger.Error("Failed to delete user", zap.String("id", id.String()), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to delete user",
"details": err.Error(),
})
return
}
c.JSON(http.StatusNoContent, nil)
}
// List handles GET /users
func (h *UserHandler) List(c *gin.Context) {
var req domain.ListUsersRequest
// Parse query parameters
if status := c.Query("status"); status != "" {
s := domain.UserStatus(status)
req.Status = &s
}
if role := c.Query("role"); role != "" {
r := domain.UserRole(role)
req.Role = &r
}
req.Search = c.Query("search")
if limit := c.Query("limit"); limit != "" {
if l, err := strconv.Atoi(limit); err == nil && l > 0 {
req.Limit = l
}
}
if offset := c.Query("offset"); offset != "" {
if o, err := strconv.Atoi(offset); err == nil && o >= 0 {
req.Offset = o
}
}
req.OrderBy = c.DefaultQuery("order_by", "created_at")
req.OrderDir = c.DefaultQuery("order_dir", "desc")
response, err := h.userService.List(c.Request.Context(), &req)
if err != nil {
h.logger.Error("Failed to list users", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to list users",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, response)
}
// GetByEmail handles GET /users/email/:email
func (h *UserHandler) GetByEmail(c *gin.Context) {
email := c.Param("email")
if email == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Email parameter is required",
})
return
}
user, err := h.userService.GetByEmail(c.Request.Context(), email)
if err != nil {
if err.Error() == "user not found" {
c.JSON(http.StatusNotFound, gin.H{
"error": "User not found",
})
return
}
h.logger.Error("Failed to get user by email", zap.String("email", email), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get user",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, user)
}
// ExistsByEmail handles GET /users/exists/:email
func (h *UserHandler) ExistsByEmail(c *gin.Context) {
email := c.Param("email")
if email == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Email parameter is required",
})
return
}
exists, err := h.userService.ExistsByEmail(c.Request.Context(), email)
if err != nil {
h.logger.Error("Failed to check user existence", zap.String("email", email), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to check user existence",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"exists": exists,
"email": email,
})
}
// Helper function to get actor from gin context
func getActorFromContext(c *gin.Context) string {
if actor, exists := c.Get("actor_id"); exists {
if actorStr, ok := actor.(string); ok {
return actorStr
}
}
// Fallback to email header if available
if email := c.GetHeader("X-User-Email"); email != "" {
return email
}
return "system"
}

View File

@ -0,0 +1,37 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/RyanCopley/skybridge/user/internal/config"
)
// Authentication middleware
func Authentication(cfg config.ConfigProvider, logger *zap.Logger) gin.HandlerFunc {
return gin.HandlerFunc(func(c *gin.Context) {
// For development, we'll use header-based authentication
if cfg.GetString("AUTH_PROVIDER") == "header" {
userEmail := c.GetHeader(cfg.GetString("AUTH_HEADER_USER_EMAIL"))
if userEmail == "" {
logger.Warn("Missing authentication header",
zap.String("header", cfg.GetString("AUTH_HEADER_USER_EMAIL")),
zap.String("path", c.Request.URL.Path))
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentication required",
})
c.Abort()
return
}
// Set actor in context for handlers
c.Set("actor_id", userEmail)
c.Set("user_email", userEmail)
}
c.Next()
})
}

View File

@ -0,0 +1,22 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
// CORS middleware for handling cross-origin requests
func CORS() gin.HandlerFunc {
return gin.HandlerFunc(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-User-Email")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
}

View File

@ -0,0 +1,34 @@
package middleware
import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Logger middleware for structured logging
func Logger(logger *zap.Logger) gin.HandlerFunc {
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
logger.Info("HTTP request",
zap.String("method", param.Method),
zap.String("path", param.Path),
zap.Int("status", param.StatusCode),
zap.Duration("latency", param.Latency),
zap.String("client_ip", param.ClientIP),
zap.String("user_agent", param.Request.UserAgent()),
)
return ""
})
}
// Recovery middleware with structured logging
func Recovery(logger *zap.Logger) gin.HandlerFunc {
return gin.RecoveryWithWriter(gin.DefaultWriter, func(c *gin.Context, recovered interface{}) {
logger.Error("Panic recovered",
zap.Any("error", recovered),
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.String("client_ip", c.ClientIP()),
)
c.AbortWithStatus(500)
})
}

View File

@ -0,0 +1,129 @@
package interfaces
import (
"context"
"github.com/google/uuid"
"github.com/RyanCopley/skybridge/user/internal/domain"
)
// UserRepository defines the interface for user data operations
type UserRepository interface {
// Create creates a new user
Create(ctx context.Context, user *domain.User) error
// GetByID retrieves a user by ID
GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error)
// GetByEmail retrieves a user by email
GetByEmail(ctx context.Context, email string) (*domain.User, error)
// Update updates an existing user
Update(ctx context.Context, user *domain.User) error
// Delete deletes a user by ID
Delete(ctx context.Context, id uuid.UUID) error
// List retrieves users with filtering and pagination
List(ctx context.Context, req *domain.ListUsersRequest) (*domain.ListUsersResponse, error)
// UpdateLastLogin updates the last login timestamp
UpdateLastLogin(ctx context.Context, id uuid.UUID) error
// Count returns the total number of users matching the filter
Count(ctx context.Context, req *domain.ListUsersRequest) (int, error)
// ExistsByEmail checks if a user exists with the given email
ExistsByEmail(ctx context.Context, email string) (bool, error)
}
// UserProfileRepository defines the interface for user profile operations
type UserProfileRepository interface {
// Create creates a new user profile
Create(ctx context.Context, profile *domain.UserProfile) error
// GetByUserID retrieves a user profile by user ID
GetByUserID(ctx context.Context, userID uuid.UUID) (*domain.UserProfile, error)
// Update updates an existing user profile
Update(ctx context.Context, profile *domain.UserProfile) error
// Delete deletes a user profile by user ID
Delete(ctx context.Context, userID uuid.UUID) error
}
// UserSessionRepository defines the interface for user session operations
type UserSessionRepository interface {
// Create creates a new user session
Create(ctx context.Context, session *domain.UserSession) error
// GetByToken retrieves a session by token
GetByToken(ctx context.Context, token string) (*domain.UserSession, error)
// GetByUserID retrieves all sessions for a user
GetByUserID(ctx context.Context, userID uuid.UUID) ([]domain.UserSession, error)
// Update updates an existing session (e.g., last used time)
Update(ctx context.Context, session *domain.UserSession) error
// Delete deletes a session by ID
Delete(ctx context.Context, id uuid.UUID) error
// DeleteByUserID deletes all sessions for a user
DeleteByUserID(ctx context.Context, userID uuid.UUID) error
// DeleteExpired deletes all expired sessions
DeleteExpired(ctx context.Context) error
// IsValidToken checks if a token is valid and not expired
IsValidToken(ctx context.Context, token string) (bool, error)
}
// AuditRepository defines the interface for audit logging
type AuditRepository interface {
// LogEvent logs an audit event
LogEvent(ctx context.Context, event *AuditEvent) error
// GetEvents retrieves audit events with filtering
GetEvents(ctx context.Context, req *GetEventsRequest) (*GetEventsResponse, error)
}
// AuditEvent represents an audit event
type AuditEvent struct {
ID uuid.UUID `json:"id" db:"id"`
Type string `json:"type" db:"type"`
Severity string `json:"severity" db:"severity"`
Status string `json:"status" db:"status"`
Timestamp string `json:"timestamp" db:"timestamp"`
ActorID string `json:"actor_id" db:"actor_id"`
ActorType string `json:"actor_type" db:"actor_type"`
ActorIP string `json:"actor_ip" db:"actor_ip"`
UserAgent string `json:"user_agent" db:"user_agent"`
ResourceID string `json:"resource_id" db:"resource_id"`
ResourceType string `json:"resource_type" db:"resource_type"`
Action string `json:"action" db:"action"`
Description string `json:"description" db:"description"`
Details map[string]interface{} `json:"details" db:"details"`
RequestID string `json:"request_id" db:"request_id"`
SessionID string `json:"session_id" db:"session_id"`
}
// GetEventsRequest represents a request to get audit events
type GetEventsRequest struct {
UserID *uuid.UUID `json:"user_id,omitempty"`
ResourceType *string `json:"resource_type,omitempty"`
Action *string `json:"action,omitempty"`
StartTime *string `json:"start_time,omitempty"`
EndTime *string `json:"end_time,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}
// GetEventsResponse represents a response for audit events
type GetEventsResponse struct {
Events []AuditEvent `json:"events"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
HasMore bool `json:"has_more"`
}

View File

@ -0,0 +1,158 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/RyanCopley/skybridge/user/internal/domain"
"github.com/RyanCopley/skybridge/user/internal/repository/interfaces"
)
type userProfileRepository struct {
db *sqlx.DB
}
// NewUserProfileRepository creates a new user profile repository
func NewUserProfileRepository(db *sqlx.DB) interfaces.UserProfileRepository {
return &userProfileRepository{db: db}
}
func (r *userProfileRepository) Create(ctx context.Context, profile *domain.UserProfile) error {
profile.CreatedAt = time.Now()
profile.UpdatedAt = time.Now()
// Convert preferences to JSON
var preferencesJSON []byte
if profile.Preferences != nil {
var err error
preferencesJSON, err = json.Marshal(profile.Preferences)
if err != nil {
return fmt.Errorf("failed to marshal preferences: %w", err)
}
}
query := `
INSERT INTO user_profiles (
user_id, bio, location, website, timezone, language,
preferences, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9
)`
_, err := r.db.ExecContext(ctx, query,
profile.UserID, profile.Bio, profile.Location, profile.Website,
profile.Timezone, profile.Language, preferencesJSON,
profile.CreatedAt, profile.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to create user profile: %w", err)
}
return nil
}
func (r *userProfileRepository) GetByUserID(ctx context.Context, userID uuid.UUID) (*domain.UserProfile, error) {
query := `
SELECT user_id, bio, location, website, timezone, language,
preferences, created_at, updated_at
FROM user_profiles
WHERE user_id = $1`
row := r.db.QueryRowContext(ctx, query, userID)
var profile domain.UserProfile
var preferencesJSON sql.NullString
err := row.Scan(
&profile.UserID, &profile.Bio, &profile.Location, &profile.Website,
&profile.Timezone, &profile.Language, &preferencesJSON,
&profile.CreatedAt, &profile.UpdatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user profile not found")
}
return nil, fmt.Errorf("failed to get user profile: %w", err)
}
// Parse preferences JSON
if preferencesJSON.Valid && preferencesJSON.String != "" {
var preferences map[string]interface{}
err = json.Unmarshal([]byte(preferencesJSON.String), &preferences)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal preferences: %w", err)
}
profile.Preferences = preferences
}
return &profile, nil
}
func (r *userProfileRepository) Update(ctx context.Context, profile *domain.UserProfile) error {
profile.UpdatedAt = time.Now()
// Convert preferences to JSON
var preferencesJSON []byte
if profile.Preferences != nil {
var err error
preferencesJSON, err = json.Marshal(profile.Preferences)
if err != nil {
return fmt.Errorf("failed to marshal preferences: %w", err)
}
}
query := `
UPDATE user_profiles SET
bio = $2,
location = $3,
website = $4,
timezone = $5,
language = $6,
preferences = $7,
updated_at = $8
WHERE user_id = $1`
result, err := r.db.ExecContext(ctx, query,
profile.UserID, profile.Bio, profile.Location, profile.Website,
profile.Timezone, profile.Language, preferencesJSON,
profile.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to update user profile: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("user profile not found")
}
return nil
}
func (r *userProfileRepository) Delete(ctx context.Context, userID uuid.UUID) error {
query := `DELETE FROM user_profiles WHERE user_id = $1`
result, err := r.db.ExecContext(ctx, query, userID)
if err != nil {
return fmt.Errorf("failed to delete user profile: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("user profile not found")
}
return nil
}

View File

@ -0,0 +1,305 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"github.com/RyanCopley/skybridge/user/internal/domain"
"github.com/RyanCopley/skybridge/user/internal/repository/interfaces"
)
type userRepository struct {
db *sqlx.DB
}
// NewUserRepository creates a new user repository
func NewUserRepository(db *sqlx.DB) interfaces.UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
query := `
INSERT INTO users (
id, email, first_name, last_name, display_name, avatar,
role, status, created_at, updated_at, created_by, updated_by
) VALUES (
:id, :email, :first_name, :last_name, :display_name, :avatar,
:role, :status, :created_at, :updated_at, :created_by, :updated_by
)`
if user.ID == uuid.Nil {
user.ID = uuid.New()
}
user.CreatedAt = time.Now()
user.UpdatedAt = time.Now()
if user.Status == "" {
user.Status = domain.UserStatusPending
}
_, err := r.db.NamedExecContext(ctx, query, user)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
return fmt.Errorf("user with email %s already exists", user.Email)
}
return fmt.Errorf("failed to create user: %w", err)
}
return nil
}
func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
query := `
SELECT id, email, first_name, last_name, display_name, avatar,
role, status, last_login_at, created_at, updated_at, created_by, updated_by
FROM users
WHERE id = $1`
var user domain.User
err := r.db.GetContext(ctx, &user, query, id)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user not found")
}
return nil, fmt.Errorf("failed to get user: %w", err)
}
return &user, nil
}
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
query := `
SELECT id, email, first_name, last_name, display_name, avatar,
role, status, last_login_at, created_at, updated_at, created_by, updated_by
FROM users
WHERE email = $1`
var user domain.User
err := r.db.GetContext(ctx, &user, query, email)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user not found")
}
return nil, fmt.Errorf("failed to get user: %w", err)
}
return &user, nil
}
func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
user.UpdatedAt = time.Now()
query := `
UPDATE users SET
email = :email,
first_name = :first_name,
last_name = :last_name,
display_name = :display_name,
avatar = :avatar,
role = :role,
status = :status,
last_login_at = :last_login_at,
updated_at = :updated_at,
updated_by = :updated_by
WHERE id = :id`
result, err := r.db.NamedExecContext(ctx, query, user)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
return fmt.Errorf("user with email %s already exists", user.Email)
}
return fmt.Errorf("failed to update user: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
}
func (r *userRepository) Delete(ctx context.Context, id uuid.UUID) error {
query := `DELETE FROM users WHERE id = $1`
result, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
}
func (r *userRepository) List(ctx context.Context, req *domain.ListUsersRequest) (*domain.ListUsersResponse, error) {
// Build WHERE clause
var conditions []string
var args []interface{}
argCounter := 1
if req.Status != nil {
conditions = append(conditions, fmt.Sprintf("status = $%d", argCounter))
args = append(args, *req.Status)
argCounter++
}
if req.Role != nil {
conditions = append(conditions, fmt.Sprintf("role = $%d", argCounter))
args = append(args, *req.Role)
argCounter++
}
if req.Search != "" {
searchPattern := "%" + strings.ToLower(req.Search) + "%"
conditions = append(conditions, fmt.Sprintf("(LOWER(email) LIKE $%d OR LOWER(first_name) LIKE $%d OR LOWER(last_name) LIKE $%d OR LOWER(display_name) LIKE $%d)", argCounter, argCounter, argCounter, argCounter))
args = append(args, searchPattern)
argCounter++
}
whereClause := ""
if len(conditions) > 0 {
whereClause = "WHERE " + strings.Join(conditions, " AND ")
}
// Build ORDER BY clause
orderBy := "created_at"
orderDir := "DESC"
if req.OrderBy != "" {
orderBy = req.OrderBy
}
if req.OrderDir != "" {
orderDir = strings.ToUpper(req.OrderDir)
}
// Set default pagination
limit := 20
if req.Limit > 0 {
limit = req.Limit
}
offset := 0
if req.Offset > 0 {
offset = req.Offset
}
// Query for users
query := fmt.Sprintf(`
SELECT id, email, first_name, last_name, display_name, avatar,
role, status, last_login_at, created_at, updated_at, created_by, updated_by
FROM users
%s
ORDER BY %s %s
LIMIT $%d OFFSET $%d`,
whereClause, orderBy, orderDir, argCounter, argCounter+1)
args = append(args, limit, offset)
var users []domain.User
err := r.db.SelectContext(ctx, &users, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to list users: %w", err)
}
// Get total count
total, err := r.Count(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to get user count: %w", err)
}
hasMore := offset+len(users) < total
return &domain.ListUsersResponse{
Users: users,
Total: total,
Limit: limit,
Offset: offset,
HasMore: hasMore,
}, nil
}
func (r *userRepository) UpdateLastLogin(ctx context.Context, id uuid.UUID) error {
query := `UPDATE users SET last_login_at = $1 WHERE id = $2`
result, err := r.db.ExecContext(ctx, query, time.Now(), id)
if err != nil {
return fmt.Errorf("failed to update last login: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
}
func (r *userRepository) Count(ctx context.Context, req *domain.ListUsersRequest) (int, error) {
var conditions []string
var args []interface{}
argCounter := 1
if req.Status != nil {
conditions = append(conditions, fmt.Sprintf("status = $%d", argCounter))
args = append(args, *req.Status)
argCounter++
}
if req.Role != nil {
conditions = append(conditions, fmt.Sprintf("role = $%d", argCounter))
args = append(args, *req.Role)
argCounter++
}
if req.Search != "" {
searchPattern := "%" + strings.ToLower(req.Search) + "%"
conditions = append(conditions, fmt.Sprintf("(LOWER(email) LIKE $%d OR LOWER(first_name) LIKE $%d OR LOWER(last_name) LIKE $%d OR LOWER(display_name) LIKE $%d)", argCounter, argCounter, argCounter, argCounter))
args = append(args, searchPattern)
argCounter++
}
whereClause := ""
if len(conditions) > 0 {
whereClause = "WHERE " + strings.Join(conditions, " AND ")
}
query := fmt.Sprintf("SELECT COUNT(*) FROM users %s", whereClause)
var count int
err := r.db.GetContext(ctx, &count, query, args...)
if err != nil {
return 0, fmt.Errorf("failed to count users: %w", err)
}
return count, nil
}
func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
query := `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`
var exists bool
err := r.db.GetContext(ctx, &exists, query, email)
if err != nil {
return false, fmt.Errorf("failed to check user existence: %w", err)
}
return exists, nil
}

View File

@ -0,0 +1,326 @@
package services
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/RyanCopley/skybridge/user/internal/domain"
"github.com/RyanCopley/skybridge/user/internal/repository/interfaces"
)
// UserService defines the interface for user business logic
type UserService interface {
// Create creates a new user
Create(ctx context.Context, req *domain.CreateUserRequest, actorID string) (*domain.User, error)
// GetByID retrieves a user by ID
GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error)
// GetByEmail retrieves a user by email
GetByEmail(ctx context.Context, email string) (*domain.User, error)
// Update updates an existing user
Update(ctx context.Context, id uuid.UUID, req *domain.UpdateUserRequest, actorID string) (*domain.User, error)
// Delete deletes a user by ID
Delete(ctx context.Context, id uuid.UUID, actorID string) error
// List retrieves users with filtering and pagination
List(ctx context.Context, req *domain.ListUsersRequest) (*domain.ListUsersResponse, error)
// UpdateLastLogin updates the last login timestamp
UpdateLastLogin(ctx context.Context, id uuid.UUID) error
// ExistsByEmail checks if a user exists with the given email
ExistsByEmail(ctx context.Context, email string) (bool, error)
}
type userService struct {
userRepo interfaces.UserRepository
profileRepo interfaces.UserProfileRepository
auditRepo interfaces.AuditRepository
logger *zap.Logger
}
// NewUserService creates a new user service
func NewUserService(
userRepo interfaces.UserRepository,
profileRepo interfaces.UserProfileRepository,
auditRepo interfaces.AuditRepository,
logger *zap.Logger,
) UserService {
return &userService{
userRepo: userRepo,
profileRepo: profileRepo,
auditRepo: auditRepo,
logger: logger,
}
}
func (s *userService) Create(ctx context.Context, req *domain.CreateUserRequest, actorID string) (*domain.User, error) {
// Validate email uniqueness
exists, err := s.userRepo.ExistsByEmail(ctx, req.Email)
if err != nil {
s.logger.Error("Failed to check email uniqueness", zap.String("email", req.Email), zap.Error(err))
return nil, fmt.Errorf("failed to validate email uniqueness: %w", err)
}
if exists {
return nil, fmt.Errorf("user with email %s already exists", req.Email)
}
// Create user domain object
user := &domain.User{
ID: uuid.New(),
Email: req.Email,
FirstName: req.FirstName,
LastName: req.LastName,
DisplayName: req.DisplayName,
Avatar: req.Avatar,
Role: req.Role,
Status: req.Status,
CreatedBy: actorID,
UpdatedBy: actorID,
}
if user.Status == "" {
user.Status = domain.UserStatusPending
}
// Create user in database
err = s.userRepo.Create(ctx, user)
if err != nil {
s.logger.Error("Failed to create user", zap.String("email", req.Email), zap.Error(err))
return nil, fmt.Errorf("failed to create user: %w", err)
}
// Create default user profile
profile := &domain.UserProfile{
UserID: user.ID,
Bio: "",
Location: "",
Website: "",
Timezone: "UTC",
Language: "en",
Preferences: make(map[string]interface{}),
}
err = s.profileRepo.Create(ctx, profile)
if err != nil {
s.logger.Warn("Failed to create user profile", zap.String("user_id", user.ID.String()), zap.Error(err))
// Don't fail user creation if profile creation fails
}
// Log audit event
if s.auditRepo != nil {
auditEvent := &interfaces.AuditEvent{
ID: uuid.New(),
Type: "user.created",
Severity: "info",
Status: "success",
Timestamp: time.Now().Format(time.RFC3339),
ActorID: actorID,
ActorType: "user",
ResourceID: user.ID.String(),
ResourceType: "user",
Action: "create",
Description: fmt.Sprintf("User %s created", user.Email),
Details: map[string]interface{}{
"user_id": user.ID.String(),
"email": user.Email,
"role": user.Role,
"status": user.Status,
},
}
_ = s.auditRepo.LogEvent(ctx, auditEvent)
}
s.logger.Info("User created successfully",
zap.String("user_id", user.ID.String()),
zap.String("email", user.Email),
zap.String("actor", actorID))
return user, nil
}
func (s *userService) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
user, err := s.userRepo.GetByID(ctx, id)
if err != nil {
s.logger.Debug("Failed to get user by ID", zap.String("id", id.String()), zap.Error(err))
return nil, err
}
return user, nil
}
func (s *userService) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
user, err := s.userRepo.GetByEmail(ctx, email)
if err != nil {
s.logger.Debug("Failed to get user by email", zap.String("email", email), zap.Error(err))
return nil, err
}
return user, nil
}
func (s *userService) Update(ctx context.Context, id uuid.UUID, req *domain.UpdateUserRequest, actorID string) (*domain.User, error) {
// Get existing user
existingUser, err := s.userRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
// Check email uniqueness if email is being updated
if req.Email != nil && *req.Email != existingUser.Email {
exists, err := s.userRepo.ExistsByEmail(ctx, *req.Email)
if err != nil {
s.logger.Error("Failed to check email uniqueness", zap.String("email", *req.Email), zap.Error(err))
return nil, fmt.Errorf("failed to validate email uniqueness: %w", err)
}
if exists {
return nil, fmt.Errorf("user with email %s already exists", *req.Email)
}
}
// Update fields
if req.Email != nil {
existingUser.Email = *req.Email
}
if req.FirstName != nil {
existingUser.FirstName = *req.FirstName
}
if req.LastName != nil {
existingUser.LastName = *req.LastName
}
if req.DisplayName != nil {
existingUser.DisplayName = *req.DisplayName
}
if req.Avatar != nil {
existingUser.Avatar = *req.Avatar
}
if req.Role != nil {
existingUser.Role = *req.Role
}
if req.Status != nil {
existingUser.Status = *req.Status
}
existingUser.UpdatedBy = actorID
// Update user in database
err = s.userRepo.Update(ctx, existingUser)
if err != nil {
s.logger.Error("Failed to update user", zap.String("id", id.String()), zap.Error(err))
return nil, fmt.Errorf("failed to update user: %w", err)
}
// Log audit event
if s.auditRepo != nil {
auditEvent := &interfaces.AuditEvent{
ID: uuid.New(),
Type: "user.updated",
Severity: "info",
Status: "success",
Timestamp: time.Now().Format(time.RFC3339),
ActorID: actorID,
ActorType: "user",
ResourceID: id.String(),
ResourceType: "user",
Action: "update",
Description: fmt.Sprintf("User %s updated", existingUser.Email),
Details: map[string]interface{}{
"user_id": id.String(),
"email": existingUser.Email,
"role": existingUser.Role,
"status": existingUser.Status,
},
}
_ = s.auditRepo.LogEvent(ctx, auditEvent)
}
s.logger.Info("User updated successfully",
zap.String("user_id", id.String()),
zap.String("email", existingUser.Email),
zap.String("actor", actorID))
return existingUser, nil
}
func (s *userService) Delete(ctx context.Context, id uuid.UUID, actorID string) error {
// Get user for audit logging
user, err := s.userRepo.GetByID(ctx, id)
if err != nil {
return err
}
// Delete user profile first
_ = s.profileRepo.Delete(ctx, id) // Don't fail if profile doesn't exist
// Delete user
err = s.userRepo.Delete(ctx, id)
if err != nil {
s.logger.Error("Failed to delete user", zap.String("id", id.String()), zap.Error(err))
return fmt.Errorf("failed to delete user: %w", err)
}
// Log audit event
if s.auditRepo != nil {
auditEvent := &interfaces.AuditEvent{
ID: uuid.New(),
Type: "user.deleted",
Severity: "warn",
Status: "success",
Timestamp: time.Now().Format(time.RFC3339),
ActorID: actorID,
ActorType: "user",
ResourceID: id.String(),
ResourceType: "user",
Action: "delete",
Description: fmt.Sprintf("User %s deleted", user.Email),
Details: map[string]interface{}{
"user_id": id.String(),
"email": user.Email,
},
}
_ = s.auditRepo.LogEvent(ctx, auditEvent)
}
s.logger.Info("User deleted successfully",
zap.String("user_id", id.String()),
zap.String("email", user.Email),
zap.String("actor", actorID))
return nil
}
func (s *userService) List(ctx context.Context, req *domain.ListUsersRequest) (*domain.ListUsersResponse, error) {
response, err := s.userRepo.List(ctx, req)
if err != nil {
s.logger.Error("Failed to list users", zap.Error(err))
return nil, fmt.Errorf("failed to list users: %w", err)
}
return response, nil
}
func (s *userService) UpdateLastLogin(ctx context.Context, id uuid.UUID) error {
err := s.userRepo.UpdateLastLogin(ctx, id)
if err != nil {
s.logger.Error("Failed to update last login", zap.String("id", id.String()), zap.Error(err))
return fmt.Errorf("failed to update last login: %w", err)
}
return nil
}
func (s *userService) ExistsByEmail(ctx context.Context, email string) (bool, error) {
exists, err := s.userRepo.ExistsByEmail(ctx, email)
if err != nil {
s.logger.Error("Failed to check email existence", zap.String("email", email), zap.Error(err))
return false, fmt.Errorf("failed to check email existence: %w", err)
}
return exists, nil
}

View File

@ -0,0 +1,92 @@
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
display_name VARCHAR(200),
avatar VARCHAR(500),
role VARCHAR(50) NOT NULL DEFAULT 'user',
status VARCHAR(50) NOT NULL DEFAULT 'pending',
last_login_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
created_by VARCHAR(255) NOT NULL,
updated_by VARCHAR(255) NOT NULL
);
-- Create user_profiles table
CREATE TABLE IF NOT EXISTS user_profiles (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
bio TEXT,
location VARCHAR(200),
website VARCHAR(500),
timezone VARCHAR(50) DEFAULT 'UTC',
language VARCHAR(10) DEFAULT 'en',
preferences JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Create user_sessions table
CREATE TABLE IF NOT EXISTS user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(1000) NOT NULL UNIQUE,
ip_address VARCHAR(45) NOT NULL,
user_agent TEXT NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Create audit_events table (similar to KMS)
CREATE TABLE IF NOT EXISTS audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(100) NOT NULL,
severity VARCHAR(20) NOT NULL DEFAULT 'info',
status VARCHAR(20) NOT NULL DEFAULT 'success',
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
actor_id VARCHAR(255) NOT NULL,
actor_type VARCHAR(50) NOT NULL DEFAULT 'user',
actor_ip VARCHAR(45),
user_agent TEXT,
resource_id VARCHAR(255),
resource_type VARCHAR(100),
action VARCHAR(100) NOT NULL,
description TEXT NOT NULL,
details JSONB DEFAULT '{}',
request_id VARCHAR(255),
session_id VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
CREATE INDEX IF NOT EXISTS idx_users_updated_at ON users(updated_at);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(token);
CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_audit_events_type ON audit_events(type);
CREATE INDEX IF NOT EXISTS idx_audit_events_actor_id ON audit_events(actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_events_resource_type ON audit_events(resource_type);
CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp ON audit_events(timestamp);
-- Add constraints
ALTER TABLE users ADD CONSTRAINT chk_users_role
CHECK (role IN ('admin', 'user', 'moderator', 'viewer'));
ALTER TABLE users ADD CONSTRAINT chk_users_status
CHECK (status IN ('active', 'inactive', 'suspended', 'pending'));
-- Create initial admin user (optional)
INSERT INTO users (
email, first_name, last_name, role, status, created_by, updated_by
) VALUES (
'admin@example.com', 'System', 'Admin', 'admin', 'active', 'system', 'system'
) ON CONFLICT (email) DO NOTHING;

BIN
user/user-service Executable file

Binary file not shown.

5926
user/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
user/web/package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "user-management",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@mantine/core": "^7.0.0",
"@mantine/hooks": "^7.0.0",
"@mantine/notifications": "^7.0.0",
"@mantine/form": "^7.0.0",
"@mantine/modals": "^7.0.0",
"@tabler/icons-react": "^2.40.0",
"axios": "^1.6.0"
},
"devDependencies": {
"@babel/core": "^7.22.0",
"@babel/preset-react": "^7.22.0",
"@babel/preset-typescript": "^7.22.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"babel-loader": "^9.1.0",
"css-loader": "^6.8.0",
"html-webpack-plugin": "^5.5.0",
"style-loader": "^3.3.0",
"typescript": "^5.1.0",
"webpack": "^5.88.0",
"webpack-cli": "^5.1.0",
"webpack-dev-server": "^4.15.0"
},
"scripts": {
"start": "webpack serve --mode=development",
"build": "webpack --mode=production",
"dev": "webpack serve --mode=development"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="User Management Microservice"
/>
<title>User Management</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

138
user/web/src/App.tsx Normal file
View File

@ -0,0 +1,138 @@
import React, { useState } from 'react';
import { Box, Title, Tabs, Stack, ActionIcon, Group, Select, MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { ModalsProvider } from '@mantine/modals';
import {
IconUsers,
IconUserPlus,
IconStar,
IconStarFilled
} from '@tabler/icons-react';
import UserManagement from './components/UserManagement';
const App: React.FC = () => {
// Determine current route based on pathname
const getCurrentRoute = () => {
const path = window.location.pathname;
if (path.includes('/create')) return 'create';
return 'users';
};
const [currentRoute, setCurrentRoute] = useState(getCurrentRoute());
const [isFavorited, setIsFavorited] = useState(false);
const [selectedColor, setSelectedColor] = useState('');
// Listen for URL changes (for when the shell navigates)
React.useEffect(() => {
const handlePopState = () => {
setCurrentRoute(getCurrentRoute());
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const handleTabChange = (value: string | null) => {
if (value) {
// Use history.pushState to update URL and notify shell router
const basePath = '/app/user';
const newPath = value === 'users' ? basePath : `${basePath}/${value}`;
// Update the URL and internal state
window.history.pushState(null, '', newPath);
setCurrentRoute(value);
// Dispatch a custom event so shell can respond if needed
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
}
};
const toggleFavorite = () => {
setIsFavorited(prev => !prev);
};
const colorOptions = [
{ value: 'red', label: 'Red' },
{ value: 'blue', label: 'Blue' },
{ value: 'green', label: 'Green' },
{ value: 'purple', label: 'Purple' },
{ value: 'orange', label: 'Orange' },
{ value: 'pink', label: 'Pink' },
{ value: 'teal', label: 'Teal' },
];
const renderContent = () => {
switch (currentRoute) {
case 'users':
case 'create':
default:
return <UserManagement />;
}
};
return (
<MantineProvider>
<ModalsProvider>
<Notifications />
<Box w="100%" pos="relative">
<Stack gap="lg">
<div>
<Group justify="space-between" align="flex-start">
<div>
<Group align="center" gap="sm" mb="xs">
<Title order={1} size="h2">
User Management
</Title>
<ActionIcon
variant="subtle"
size="lg"
onClick={toggleFavorite}
aria-label={isFavorited ? "Remove from favorites" : "Add to favorites"}
>
{isFavorited ? (
<IconStarFilled size={20} color="gold" />
) : (
<IconStar size={20} />
)}
</ActionIcon>
</Group>
</div>
{/* Right-side controls */}
<Group align="flex-start" gap="lg">
<div>
<Select
placeholder="Choose a color"
data={colorOptions}
value={selectedColor}
onChange={(value) => setSelectedColor(value || '')}
size="sm"
w={150}
/>
</div>
</Group>
</Group>
</div>
<Tabs value={currentRoute} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab
value="users"
leftSection={<IconUsers size={16} />}
>
Users
</Tabs.Tab>
</Tabs.List>
<Box pt="md">
{renderContent()}
</Box>
</Tabs>
</Stack>
</Box>
</ModalsProvider>
</MantineProvider>
);
};
export default App;

View File

@ -0,0 +1,221 @@
import React, { useEffect } from 'react';
import {
Modal,
TextInput,
Select,
Button,
Group,
Stack,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { userService } from '../services/userService';
import { User, CreateUserRequest, UpdateUserRequest, UserRole, UserStatus } from '../types/user';
interface UserFormProps {
opened: boolean;
onClose: () => void;
onSuccess: () => void;
editUser?: User | null;
}
const UserForm: React.FC<UserFormProps> = ({
opened,
onClose,
onSuccess,
editUser,
}) => {
const isEditing = !!editUser;
const form = useForm({
initialValues: {
email: '',
first_name: '',
last_name: '',
display_name: '',
avatar: '',
role: 'user' as UserRole,
status: 'pending' as UserStatus,
},
validate: {
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
first_name: (value) => (value.trim().length < 1 ? 'First name is required' : null),
last_name: (value) => (value.trim().length < 1 ? 'Last name is required' : null),
},
});
// Update form values when editUser changes
useEffect(() => {
if (editUser) {
form.setValues({
email: editUser.email || '',
first_name: editUser.first_name || '',
last_name: editUser.last_name || '',
display_name: editUser.display_name || '',
avatar: editUser.avatar || '',
role: editUser.role || 'user' as UserRole,
status: editUser.status || 'pending' as UserStatus,
});
} else {
// Reset to default values when not editing
form.setValues({
email: '',
first_name: '',
last_name: '',
display_name: '',
avatar: '',
role: 'user' as UserRole,
status: 'pending' as UserStatus,
});
}
}, [editUser, opened]);
const handleSubmit = async (values: typeof form.values) => {
try {
if (isEditing && editUser) {
const updateRequest: UpdateUserRequest = {
email: values.email !== editUser.email ? values.email : undefined,
first_name: values.first_name !== editUser.first_name ? values.first_name : undefined,
last_name: values.last_name !== editUser.last_name ? values.last_name : undefined,
display_name: values.display_name !== editUser.display_name ? values.display_name : undefined,
avatar: values.avatar !== editUser.avatar ? values.avatar : undefined,
role: values.role !== editUser.role ? values.role : undefined,
status: values.status !== editUser.status ? values.status : undefined,
};
// Only send fields that have changed
const hasChanges = Object.values(updateRequest).some(value => value !== undefined);
if (!hasChanges) {
notifications.show({
title: 'No Changes',
message: 'No changes detected',
color: 'blue',
});
onClose();
return;
}
await userService.updateUser(editUser.id, updateRequest);
notifications.show({
title: 'Success',
message: 'User updated successfully',
color: 'green',
});
} else {
const createRequest: CreateUserRequest = {
email: values.email,
first_name: values.first_name,
last_name: values.last_name,
display_name: values.display_name || undefined,
avatar: values.avatar || undefined,
role: values.role,
status: values.status,
};
await userService.createUser(createRequest);
notifications.show({
title: 'Success',
message: 'User created successfully',
color: 'green',
});
}
onSuccess();
onClose();
form.reset();
} catch (error: any) {
console.error('Failed to save user:', error);
notifications.show({
title: 'Error',
message: error.message || 'Failed to save user',
color: 'red',
});
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={isEditing ? 'Edit User' : 'Create User'}
size="md"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Group grow>
<TextInput
label="First Name"
placeholder="Enter first name"
required
{...form.getInputProps('first_name')}
/>
<TextInput
label="Last Name"
placeholder="Enter last name"
required
{...form.getInputProps('last_name')}
/>
</Group>
<TextInput
label="Display Name"
placeholder="Enter display name (optional)"
{...form.getInputProps('display_name')}
/>
<TextInput
label="Email"
placeholder="Enter email address"
required
type="email"
{...form.getInputProps('email')}
/>
<TextInput
label="Avatar URL"
placeholder="Enter avatar URL (optional)"
{...form.getInputProps('avatar')}
/>
<Group grow>
<Select
label="Role"
placeholder="Select role"
required
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'moderator', label: 'Moderator' },
{ value: 'user', label: 'User' },
{ value: 'viewer', label: 'Viewer' },
]}
{...form.getInputProps('role')}
/>
<Select
label="Status"
placeholder="Select status"
required
data={[
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'pending', label: 'Pending' },
]}
{...form.getInputProps('status')}
/>
</Group>
<Group justify="flex-end" mt="md">
<Button variant="light" onClick={onClose}>
Cancel
</Button>
<Button type="submit">
{isEditing ? 'Update' : 'Create'} User
</Button>
</Group>
</Stack>
</form>
</Modal>
);
};
export default UserForm;

View File

@ -0,0 +1,310 @@
import React, { useState, useEffect } from 'react';
import {
Button,
Group,
Paper,
Text,
Table,
Badge,
ActionIcon,
TextInput,
Select,
Pagination,
Stack,
LoadingOverlay,
Avatar,
} from '@mantine/core';
import {
IconPlus,
IconEdit,
IconTrash,
IconSearch,
IconUser,
IconMail,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { modals } from '@mantine/modals';
import UserForm from './UserForm';
import { userService } from '../services/userService';
import { User, UserStatus, UserRole, ListUsersRequest } from '../types/user';
const UserManagement: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [roleFilter, setRoleFilter] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalUsers, setTotalUsers] = useState(0);
const [userFormOpened, setUserFormOpened] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const pageSize = 10;
const loadUsers = async (page: number = currentPage) => {
setLoading(true);
try {
const request: ListUsersRequest = {
search: searchTerm || undefined,
status: statusFilter as UserStatus || undefined,
role: roleFilter as UserRole || undefined,
limit: pageSize,
offset: (page - 1) * pageSize,
order_by: 'created_at',
order_dir: 'desc',
};
const response = await userService.listUsers(request);
setUsers(response.users);
setTotalUsers(response.total);
setCurrentPage(page);
} catch (error) {
console.error('Failed to load users:', error);
notifications.show({
title: 'Error',
message: 'Failed to load users',
color: 'red',
});
} finally {
setLoading(false);
}
};
useEffect(() => {
loadUsers(1);
}, [searchTerm, statusFilter, roleFilter]);
const handleCreateUser = () => {
setEditingUser(null);
setUserFormOpened(true);
};
const handleEditUser = (user: User) => {
setEditingUser(user);
setUserFormOpened(true);
};
const handleDeleteUser = (user: User) => {
modals.openConfirmModal({
title: 'Delete User',
children: (
<Text size="sm">
Are you sure you want to delete {user.first_name} {user.last_name}? This action cannot be undone.
</Text>
),
labels: { confirm: 'Delete', cancel: 'Cancel' },
confirmProps: { color: 'red' },
onConfirm: async () => {
try {
await userService.deleteUser(user.id);
notifications.show({
title: 'Success',
message: 'User deleted successfully',
color: 'green',
});
loadUsers();
} catch (error) {
console.error('Failed to delete user:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete user',
color: 'red',
});
}
},
});
};
const handleUserFormSuccess = () => {
loadUsers();
};
const handleUserFormClose = () => {
setUserFormOpened(false);
setEditingUser(null);
};
const getStatusColor = (status: UserStatus): string => {
switch (status) {
case 'active': return 'green';
case 'inactive': return 'gray';
case 'suspended': return 'red';
case 'pending': return 'yellow';
default: return 'gray';
}
};
const getRoleColor = (role: UserRole): string => {
switch (role) {
case 'admin': return 'red';
case 'moderator': return 'orange';
case 'user': return 'blue';
case 'viewer': return 'gray';
default: return 'gray';
}
};
const totalPages = Math.ceil(totalUsers / pageSize);
return (
<>
{/* Main user management interface */}
<Stack gap="md">
<Group justify="space-between">
<Button leftSection={<IconPlus size={16} />} onClick={handleCreateUser}>
Add User
</Button>
</Group>
<Paper p="md" withBorder>
<Group justify="space-between" mb="md">
<Group gap="sm">
<TextInput
placeholder="Search users..."
leftSection={<IconSearch size={16} />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.currentTarget.value)}
style={{ minWidth: 250 }}
/>
<Select
placeholder="Filter by status"
data={[
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'pending', label: 'Pending' },
]}
value={statusFilter}
onChange={setStatusFilter}
clearable
/>
<Select
placeholder="Filter by role"
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'moderator', label: 'Moderator' },
{ value: 'user', label: 'User' },
{ value: 'viewer', label: 'Viewer' },
]}
value={roleFilter}
onChange={setRoleFilter}
clearable
/>
</Group>
<Text size="sm" c="dimmed">
{totalUsers} users found
</Text>
</Group>
<div style={{ position: 'relative' }}>
<LoadingOverlay visible={loading} />
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>User</Table.Th>
<Table.Th>Email</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{users.map((user) => (
<Table.Tr key={user.id}>
<Table.Td>
<Group gap="sm">
<Avatar
src={user.avatar || null}
radius="sm"
size={32}
>
<IconUser size={16} />
</Avatar>
<div>
<Text size="sm" fw={500}>
{user.display_name || `${user.first_name} ${user.last_name}`}
</Text>
<Text size="xs" c="dimmed">
{user.first_name} {user.last_name}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Group gap="xs">
<IconMail size={14} />
<Text size="sm">{user.email}</Text>
</Group>
</Table.Td>
<Table.Td>
<Badge color={getRoleColor(user.role)} variant="light">
{user.role}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={getStatusColor(user.status)} variant="light">
{user.status}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">
{new Date(user.created_at).toLocaleDateString()}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="light"
size="sm"
onClick={() => handleEditUser(user)}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="light"
color="red"
size="sm"
onClick={() => handleDeleteUser(user)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{users.length === 0 && !loading && (
<Text ta="center" py="xl" c="dimmed">
No users found
</Text>
)}
</div>
{totalPages > 1 && (
<Group justify="center" mt="md">
<Pagination
value={currentPage}
onChange={loadUsers}
total={totalPages}
size="sm"
/>
</Group>
)}
</Paper>
</Stack>
{/* User form modal */}
<UserForm
opened={userFormOpened}
onClose={handleUserFormClose}
onSuccess={handleUserFormSuccess}
editUser={editingUser}
/>
</>
);
};
export default UserManagement;

13
user/web/src/index.tsx Normal file
View File

@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,109 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import {
User,
CreateUserRequest,
UpdateUserRequest,
ListUsersRequest,
ListUsersResponse,
ExistsByEmailResponse,
} from '../types/user';
class UserService {
private api: AxiosInstance;
private baseURL: string;
constructor() {
this.baseURL = process.env.REACT_APP_USER_API_URL || 'http://localhost:8090';
this.api = axios.create({
baseURL: this.baseURL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Add request interceptor for authentication
this.api.interceptors.request.use((config) => {
// For development, use header-based authentication
// In production, this might use JWT tokens or other auth mechanisms
const userEmail = 'admin@example.com'; // This would come from auth context
config.headers['X-User-Email'] = userEmail;
return config;
});
// Add response interceptor for error handling
this.api.interceptors.response.use(
(response: AxiosResponse) => response,
(error) => {
console.error('API Error:', error);
if (error.response?.data?.error) {
throw new Error(error.response.data.error);
}
throw error;
}
);
}
async createUser(userData: CreateUserRequest): Promise<User> {
const response = await this.api.post<User>('/api/users', userData);
return response.data;
}
async getUserById(id: string): Promise<User> {
const response = await this.api.get<User>(`/api/users/${id}`);
return response.data;
}
async getUserByEmail(email: string): Promise<User> {
const response = await this.api.get<User>(`/api/users/email/${encodeURIComponent(email)}`);
return response.data;
}
async updateUser(id: string, userData: UpdateUserRequest): Promise<User> {
const response = await this.api.put<User>(`/api/users/${id}`, userData);
return response.data;
}
async deleteUser(id: string): Promise<void> {
await this.api.delete(`/api/users/${id}`);
}
async listUsers(request: ListUsersRequest = {}): Promise<ListUsersResponse> {
const params = new URLSearchParams();
if (request.status) params.append('status', request.status);
if (request.role) params.append('role', request.role);
if (request.search) params.append('search', request.search);
if (request.limit) params.append('limit', request.limit.toString());
if (request.offset) params.append('offset', request.offset.toString());
if (request.order_by) params.append('order_by', request.order_by);
if (request.order_dir) params.append('order_dir', request.order_dir);
const response = await this.api.get<ListUsersResponse>(`/api/users?${params.toString()}`);
return response.data;
}
async existsByEmail(email: string): Promise<ExistsByEmailResponse> {
const response = await this.api.get<ExistsByEmailResponse>(`/api/users/exists/${encodeURIComponent(email)}`);
return response.data;
}
async health(): Promise<Record<string, any>> {
const response = await this.api.get<Record<string, any>>('/health');
return response.data;
}
// Utility method to check service availability
async isServiceAvailable(): Promise<boolean> {
try {
await this.health();
return true;
} catch (error) {
console.error('User service is not available:', error);
return false;
}
}
}
export const userService = new UserService();

View File

@ -0,0 +1,82 @@
export type UserStatus = 'active' | 'inactive' | 'suspended' | 'pending';
export type UserRole = 'admin' | 'user' | 'moderator' | 'viewer';
export interface User {
id: string;
email: string;
first_name: string;
last_name: string;
display_name?: string;
avatar?: string;
role: UserRole;
status: UserStatus;
last_login_at?: string;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string;
}
export interface UserProfile {
user_id: string;
bio?: string;
location?: string;
website?: string;
timezone?: string;
language?: string;
preferences?: Record<string, any>;
created_at: string;
updated_at: string;
}
export interface CreateUserRequest {
email: string;
first_name: string;
last_name: string;
display_name?: string;
avatar?: string;
role: UserRole;
status?: UserStatus;
}
export interface UpdateUserRequest {
email?: string;
first_name?: string;
last_name?: string;
display_name?: string;
avatar?: string;
role?: UserRole;
status?: UserStatus;
}
export interface UpdateUserProfileRequest {
bio?: string;
location?: string;
website?: string;
timezone?: string;
language?: string;
preferences?: Record<string, any>;
}
export interface ListUsersRequest {
status?: UserStatus;
role?: UserRole;
search?: string;
limit?: number;
offset?: number;
order_by?: string;
order_dir?: string;
}
export interface ListUsersResponse {
users: User[];
total: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface ExistsByEmailResponse {
exists: boolean;
email: string;
}

View File

@ -0,0 +1,87 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const webpack = require('webpack');
// Import the microfrontends registry
const { getExposesConfig } = require('../../web/src/microfrontends.js');
module.exports = {
mode: 'development',
entry: './src/index.tsx',
devServer: {
port: 3004,
headers: {
'Access-Control-Allow-Origin': '*',
},
},
resolve: {
extensions: ['.tsx', '.ts', '.js', '.jsx'],
},
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-react',
'@babel/preset-typescript',
],
},
},
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'user',
filename: 'remoteEntry.js',
exposes: getExposesConfig('user'),
shared: {
react: {
singleton: true,
requiredVersion: '^18.2.0',
eager: false,
},
'react-dom': {
singleton: true,
requiredVersion: '^18.2.0',
eager: false,
},
'@mantine/core': {
singleton: true,
requiredVersion: '^7.0.0',
eager: false,
},
'@mantine/hooks': {
singleton: true,
requiredVersion: '^7.0.0',
eager: false,
},
'@mantine/notifications': {
singleton: true,
requiredVersion: '^7.0.0',
eager: false,
},
'@tabler/icons-react': {
singleton: true,
requiredVersion: '^2.40.0',
eager: false,
},
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
new webpack.DefinePlugin({
'process.env': JSON.stringify(process.env),
}),
],
};

View File

@ -12,12 +12,15 @@ const DemoApp = React.lazy(() => import('demo/src/App'));
const KMSApp = React.lazy(() => import('kms/src/App'));
// @ts-ignore - These modules are loaded at runtime via Module Federation
const FaaSApp = React.lazy(() => import('faas/src/App'));
// @ts-ignore - These modules are loaded at runtime via Module Federation
const UserApp = React.lazy(() => import('user/src/App'));
// Map app names to components
const appComponents: Record<string, React.LazyExoticComponent<React.ComponentType<any>>> = {
demo: DemoApp,
kms: KMSApp,
faas: FaaSApp,
user: UserApp,
};
const AppLoader: React.FC = () => {

View File

@ -6,6 +6,7 @@ import {
IconKey,
IconFunction,
IconApps,
IconUsers,
} from '@tabler/icons-react';
import { microFrontends } from '../microfrontends.js';
@ -27,6 +28,11 @@ const getAppInfo = (id: string) => {
icon: IconFunction,
category: 'Development',
},
user: {
label: 'User Management',
icon: IconUsers,
category: 'Administration',
},
};
return appInfo[id] || {

View File

@ -24,6 +24,13 @@ export const microFrontends = {
exposedModule: './src/App',
importPath: 'faas/src/App',
},
user: {
name: 'user',
url: 'http://localhost:3004',
port: 3004,
exposedModule: './src/App',
importPath: 'user/src/App',
},
};
// Generate remotes configuration for Module Federation