-
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'));
|
const KMSApp = React.lazy(() => import('kms/src/App'));
|
||||||
// @ts-ignore - These modules are loaded at runtime via Module Federation
|
// @ts-ignore - These modules are loaded at runtime via Module Federation
|
||||||
const FaaSApp = React.lazy(() => import('faas/src/App'));
|
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
|
// Map app names to components
|
||||||
const appComponents: Record<string, React.LazyExoticComponent<React.ComponentType<any>>> = {
|
const appComponents: Record<string, React.LazyExoticComponent<React.ComponentType<any>>> = {
|
||||||
demo: DemoApp,
|
demo: DemoApp,
|
||||||
kms: KMSApp,
|
kms: KMSApp,
|
||||||
faas: FaaSApp,
|
faas: FaaSApp,
|
||||||
|
user: UserApp,
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppLoader: React.FC = () => {
|
const AppLoader: React.FC = () => {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
IconKey,
|
IconKey,
|
||||||
IconFunction,
|
IconFunction,
|
||||||
IconApps,
|
IconApps,
|
||||||
|
IconUsers,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { microFrontends } from '../microfrontends.js';
|
import { microFrontends } from '../microfrontends.js';
|
||||||
|
|
||||||
@ -27,6 +28,11 @@ const getAppInfo = (id: string) => {
|
|||||||
icon: IconFunction,
|
icon: IconFunction,
|
||||||
category: 'Development',
|
category: 'Development',
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
label: 'User Management',
|
||||||
|
icon: IconUsers,
|
||||||
|
category: 'Administration',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return appInfo[id] || {
|
return appInfo[id] || {
|
||||||
|
|||||||
@ -24,6 +24,13 @@ export const microFrontends = {
|
|||||||
exposedModule: './src/App',
|
exposedModule: './src/App',
|
||||||
importPath: 'faas/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
|
// Generate remotes configuration for Module Federation
|
||||||
|
|||||||
Reference in New Issue
Block a user