-
This commit is contained in:
@ -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
271
user/CLAUDE.md
Normal 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
48
user/Dockerfile
Normal 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
248
user/README.md
Normal 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
270
user/client/client.go
Normal 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
182
user/cmd/server/main.go
Normal 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
60
user/docker-compose.yml
Normal 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
42
user/go.mod
Normal 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
109
user/go.sum
Normal 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=
|
||||
166
user/internal/config/config.go
Normal file
166
user/internal/config/config.go
Normal 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"
|
||||
}
|
||||
34
user/internal/database/database.go
Normal file
34
user/internal/database/database.go
Normal 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
|
||||
}
|
||||
130
user/internal/domain/models.go
Normal file
130
user/internal/domain/models.go
Normal 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"`
|
||||
}
|
||||
52
user/internal/handlers/health_handler.go
Normal file
52
user/internal/handlers/health_handler.go
Normal 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",
|
||||
})
|
||||
}
|
||||
280
user/internal/handlers/user_handler.go
Normal file
280
user/internal/handlers/user_handler.go
Normal 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"
|
||||
}
|
||||
37
user/internal/middleware/auth.go
Normal file
37
user/internal/middleware/auth.go
Normal 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()
|
||||
})
|
||||
}
|
||||
22
user/internal/middleware/cors.go
Normal file
22
user/internal/middleware/cors.go
Normal 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()
|
||||
})
|
||||
}
|
||||
34
user/internal/middleware/logging.go
Normal file
34
user/internal/middleware/logging.go
Normal 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)
|
||||
})
|
||||
}
|
||||
129
user/internal/repository/interfaces/interfaces.go
Normal file
129
user/internal/repository/interfaces/interfaces.go
Normal 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"`
|
||||
}
|
||||
158
user/internal/repository/postgres/profile_repository.go
Normal file
158
user/internal/repository/postgres/profile_repository.go
Normal 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
|
||||
}
|
||||
305
user/internal/repository/postgres/user_repository.go
Normal file
305
user/internal/repository/postgres/user_repository.go
Normal 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
|
||||
}
|
||||
326
user/internal/services/user_service.go
Normal file
326
user/internal/services/user_service.go
Normal 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
|
||||
}
|
||||
92
user/migrations/001_initial_schema.up.sql
Executable file
92
user/migrations/001_initial_schema.up.sql
Executable 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
BIN
user/user-service
Executable file
Binary file not shown.
5926
user/web/package-lock.json
generated
Normal file
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
48
user/web/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
17
user/web/public/index.html
Normal file
17
user/web/public/index.html
Normal 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
138
user/web/src/App.tsx
Normal 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;
|
||||
221
user/web/src/components/UserForm.tsx
Normal file
221
user/web/src/components/UserForm.tsx
Normal 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;
|
||||
310
user/web/src/components/UserManagement.tsx
Normal file
310
user/web/src/components/UserManagement.tsx
Normal 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
13
user/web/src/index.tsx
Normal 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>
|
||||
);
|
||||
109
user/web/src/services/userService.ts
Normal file
109
user/web/src/services/userService.ts
Normal 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();
|
||||
82
user/web/src/types/user.ts
Normal file
82
user/web/src/types/user.ts
Normal 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;
|
||||
}
|
||||
87
user/web/webpack.config.js
Normal file
87
user/web/webpack.config.js
Normal 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),
|
||||
}),
|
||||
],
|
||||
};
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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] || {
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user