Init Eslogad
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,242 @@
|
||||
# Internal Architecture
|
||||
|
||||
This document describes the clean architecture implementation for the POS backend with complete separation of concerns between database entities, business models, and constants.
|
||||
|
||||
## 📁 Package Structure
|
||||
|
||||
### `/constants` - Business Constants
|
||||
- **Purpose**: All business logic constants, enums, and validation helpers
|
||||
- **Usage**: Used by models, services, and validation layers
|
||||
- **Features**:
|
||||
- Type-safe enums (UserRole, OrderStatus, PaymentStatus, etc.)
|
||||
- Business validation functions (IsValidUserRole, etc.)
|
||||
- Default values and limits
|
||||
- No dependencies on database or frameworks
|
||||
|
||||
### `/entities` - Database Models
|
||||
- **Purpose**: Database-specific models with GORM tags and hooks
|
||||
- **Usage**: **ONLY** used by repository layer for database operations
|
||||
- **Features**:
|
||||
- GORM annotations (`gorm:` tags)
|
||||
- Database relationships and constraints
|
||||
- BeforeCreate/AfterCreate hooks
|
||||
- Table name specifications
|
||||
- SQL-specific data types
|
||||
- **Never used in business logic**
|
||||
|
||||
### `/models` - Business Models
|
||||
- **Purpose**: **Pure** business domain models without any framework dependencies
|
||||
- **Usage**: Used by services, handlers, and business logic
|
||||
- **Features**:
|
||||
- Clean JSON serialization (`json:` tags)
|
||||
- Validation rules (`validate:` tags)
|
||||
- Request/Response DTOs
|
||||
- **Zero GORM dependencies**
|
||||
- **Zero database annotations**
|
||||
- Uses constants package for type safety
|
||||
- Pure business logic methods
|
||||
|
||||
### `/mappers` - Data Transformation
|
||||
- **Purpose**: Convert between entities and business models
|
||||
- **Usage**: Bridge between repository and service layers
|
||||
- **Features**:
|
||||
- Entity ↔ Model conversion functions
|
||||
- Request DTO → Entity conversion
|
||||
- Entity → Response DTO conversion
|
||||
- Null-safe conversions
|
||||
- Slice/collection conversions
|
||||
- Type conversions between constants and entities
|
||||
|
||||
### `/repository` - Data Access Layer
|
||||
- **Purpose**: Database operations using entities exclusively
|
||||
- **Usage**: Only works with database entities
|
||||
- **Features**:
|
||||
- CRUD operations with entities
|
||||
- Query methods with entities
|
||||
- **Private repository implementations**
|
||||
- Interface-based contracts
|
||||
- **Never references business models**
|
||||
|
||||
## 🔄 Data Flow
|
||||
|
||||
```
|
||||
API Request (JSON)
|
||||
↓
|
||||
Request DTO (models)
|
||||
↓
|
||||
Business Logic (services with models + constants)
|
||||
↓
|
||||
Entity (via mapper)
|
||||
↓
|
||||
Repository Layer (entities only)
|
||||
↓
|
||||
Database
|
||||
↓
|
||||
Entity (from database)
|
||||
↓
|
||||
Business Model (via mapper)
|
||||
↓
|
||||
Response DTO (models)
|
||||
↓
|
||||
API Response (JSON)
|
||||
```
|
||||
|
||||
## 🎯 Key Design Principles
|
||||
|
||||
### ✅ **Clean Business Models**
|
||||
```go
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Role constants.UserRole `json:"role"`
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `gorm:"primaryKey" json:"id"`
|
||||
Role string `gorm:"size:50" json:"role"`
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ **Type-Safe Constants**
|
||||
```go
|
||||
|
||||
type UserRole string
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
)
|
||||
func IsValidUserRole(role UserRole) bool { /* ... */ }
|
||||
```
|
||||
|
||||
```go
|
||||
|
||||
const AdminRole = "admin" ```
|
||||
|
||||
### ✅ **Repository Isolation**
|
||||
```go
|
||||
|
||||
func (r *userRepository) Create(ctx context.Context, user *entities.User) error {
|
||||
return r.db.Create(user).Error
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
|
||||
func (r *userRepository) Create(ctx context.Context, user *models.User) error {
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 Example Usage
|
||||
|
||||
### Service Layer (Business Logic)
|
||||
```go
|
||||
func (s *userService) CreateUser(req *models.UserCreateRequest) (*models.UserResponse, error) {
|
||||
if !constants.IsValidUserRole(req.Role) {
|
||||
return nil, errors.New("invalid role")
|
||||
}
|
||||
|
||||
entity := mappers.UserCreateRequestToEntity(req, hashedPassword)
|
||||
|
||||
err := s.userRepo.Create(ctx, entity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mappers.UserEntityToResponse(entity), nil
|
||||
}
|
||||
```
|
||||
|
||||
### Repository Layer (Data Access)
|
||||
```go
|
||||
func (r *userRepository) Create(ctx context.Context, user *entities.User) error {
|
||||
return r.db.WithContext(ctx).Create(user).Error
|
||||
}
|
||||
```
|
||||
|
||||
### Handler Layer (API)
|
||||
```go
|
||||
func (h *userHandler) CreateUser(c *gin.Context) {
|
||||
var req models.UserCreateRequest
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.userService.CreateUser(&req)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(201, resp)
|
||||
}
|
||||
```
|
||||
|
||||
## 🏗️ Architecture Benefits
|
||||
|
||||
1. **🎯 Single Responsibility**: Each package has one clear purpose
|
||||
2. **🔒 Zero Database Leakage**: Business logic never sees database concerns
|
||||
3. **🧪 Testability**: Easy to mock interfaces and test business logic
|
||||
4. **🔧 Maintainability**: Changes to database don't affect business models
|
||||
5. **🚀 Flexibility**: Can change ORM without touching business logic
|
||||
6. **📜 API Stability**: Business models provide stable contracts
|
||||
7. **🛡️ Type Safety**: Constants package prevents invalid states
|
||||
8. **🧹 Clean Code**: No mixed concerns anywhere in the codebase
|
||||
|
||||
## 📋 Development Guidelines
|
||||
|
||||
### Constants Package (`/constants`)
|
||||
- ✅ Define all business enums and constants
|
||||
- ✅ Provide validation helper functions
|
||||
- ✅ Include default values and limits
|
||||
- ❌ Never import database or framework packages
|
||||
- ❌ No business logic, only constants and validation
|
||||
|
||||
### Models Package (`/models`)
|
||||
- ✅ Pure business structs with JSON tags only
|
||||
- ✅ Use constants package for type safety
|
||||
- ✅ Include validation tags for input validation
|
||||
- ✅ Separate Request/Response DTOs
|
||||
- ✅ Add business logic methods (validation, calculations)
|
||||
- ❌ **NEVER** include GORM tags or database annotations
|
||||
- ❌ **NEVER** import database packages
|
||||
- ❌ No database relationships or foreign keys
|
||||
|
||||
### Entities Package (`/entities`)
|
||||
- ✅ Include GORM tags and database constraints
|
||||
- ✅ Define relationships and foreign keys
|
||||
- ✅ Add database hooks (BeforeCreate, etc.)
|
||||
- ✅ Use database-specific types
|
||||
- ❌ **NEVER** use in business logic or handlers
|
||||
- ❌ **NEVER** add business validation rules
|
||||
|
||||
### Mappers Package (`/mappers`)
|
||||
- ✅ Always check for nil inputs
|
||||
- ✅ Handle type conversions between constants and strings
|
||||
- ✅ Provide slice conversion helpers
|
||||
- ✅ Keep conversions simple and direct
|
||||
- ❌ No business logic in mappers
|
||||
- ❌ No database operations
|
||||
|
||||
### Repository Package (`/repository`)
|
||||
- ✅ Work exclusively with entities
|
||||
- ✅ Use private repository implementations
|
||||
- ✅ Provide clean interface contracts
|
||||
- ❌ **NEVER** reference business models
|
||||
- ❌ **NEVER** import models package
|
||||
|
||||
## 🚀 Migration Complete
|
||||
|
||||
**All packages have been successfully reorganized:**
|
||||
|
||||
- ✅ **4 Constants files** - All business constants moved to type-safe enums
|
||||
- ✅ **10 Clean Model files** - Zero GORM dependencies, pure business logic
|
||||
- ✅ **11 Entity files** - Database-only models with GORM tags
|
||||
- ✅ **11 Repository files** - Updated to use entities exclusively
|
||||
- ✅ **2 Mapper files** - Handle conversions between layers
|
||||
- ✅ **Complete separation** - No cross-layer dependencies
|
||||
|
||||
**The codebase now follows strict clean architecture principles with complete separation of database concerns from business logic!** 🎉
|
||||
@@ -0,0 +1,165 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"eslogad-be/config"
|
||||
"eslogad-be/internal/client"
|
||||
"eslogad-be/internal/handler"
|
||||
"eslogad-be/internal/middleware"
|
||||
"eslogad-be/internal/processor"
|
||||
"eslogad-be/internal/repository"
|
||||
"eslogad-be/internal/router"
|
||||
"eslogad-be/internal/service"
|
||||
"eslogad-be/internal/validator"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
server *http.Server
|
||||
db *gorm.DB
|
||||
router *router.Router
|
||||
shutdown chan os.Signal
|
||||
}
|
||||
|
||||
func NewApp(db *gorm.DB) *App {
|
||||
return &App{
|
||||
db: db,
|
||||
shutdown: make(chan os.Signal, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) Initialize(cfg *config.Config) error {
|
||||
repos := a.initRepositories()
|
||||
processors := a.initProcessors(cfg, repos)
|
||||
services := a.initServices(processors, repos, cfg)
|
||||
middlewares := a.initMiddleware(services)
|
||||
healthHandler := handler.NewHealthHandler()
|
||||
fileHandler := handler.NewFileHandler(services.fileService)
|
||||
|
||||
a.router = router.NewRouter(
|
||||
cfg,
|
||||
handler.NewAuthHandler(services.authService),
|
||||
middlewares.authMiddleware,
|
||||
healthHandler,
|
||||
handler.NewUserHandler(services.userService, &validator.UserValidatorImpl{}),
|
||||
fileHandler,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) Start(port string) error {
|
||||
engine := a.router.Init()
|
||||
|
||||
a.server = &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: engine,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
signal.Notify(a.shutdown, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
log.Printf("Server starting on port %s", port)
|
||||
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-a.shutdown
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := a.server.Shutdown(ctx); err != nil {
|
||||
log.Printf("Server forced to shutdown: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("Server exited gracefully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) Shutdown() {
|
||||
close(a.shutdown)
|
||||
}
|
||||
|
||||
type repositories struct {
|
||||
userRepo *repository.UserRepositoryImpl
|
||||
userProfileRepo *repository.UserProfileRepository
|
||||
titleRepo *repository.TitleRepository
|
||||
}
|
||||
|
||||
func (a *App) initRepositories() *repositories {
|
||||
return &repositories{
|
||||
userRepo: repository.NewUserRepository(a.db),
|
||||
userProfileRepo: repository.NewUserProfileRepository(a.db),
|
||||
titleRepo: repository.NewTitleRepository(a.db),
|
||||
}
|
||||
}
|
||||
|
||||
type processors struct {
|
||||
userProcessor *processor.UserProcessorImpl
|
||||
}
|
||||
|
||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||
return &processors{
|
||||
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo),
|
||||
}
|
||||
}
|
||||
|
||||
type services struct {
|
||||
userService *service.UserServiceImpl
|
||||
authService *service.AuthServiceImpl
|
||||
fileService *service.FileServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
authConfig := cfg.Auth()
|
||||
jwtSecret := authConfig.AccessTokenSecret()
|
||||
authService := service.NewAuthService(processors.userProcessor, jwtSecret)
|
||||
|
||||
userSvc := service.NewUserService(processors.userProcessor, repos.titleRepo)
|
||||
|
||||
// File storage client and service
|
||||
fileCfg := cfg.S3Config
|
||||
s3Client := client.NewFileClient(fileCfg)
|
||||
fileSvc := service.NewFileService(s3Client, processors.userProcessor, "profile", "documents")
|
||||
|
||||
return &services{
|
||||
userService: userSvc,
|
||||
authService: authService,
|
||||
fileService: fileSvc,
|
||||
}
|
||||
}
|
||||
|
||||
type middlewares struct {
|
||||
authMiddleware *middleware.AuthMiddleware
|
||||
}
|
||||
|
||||
func (a *App) initMiddleware(services *services) *middlewares {
|
||||
return &middlewares{
|
||||
authMiddleware: middleware.NewAuthMiddleware(services.authService),
|
||||
}
|
||||
}
|
||||
|
||||
type validators struct {
|
||||
userValidator *validator.UserValidatorImpl
|
||||
}
|
||||
|
||||
func (a *App) initValidators() *validators {
|
||||
return &validators{
|
||||
userValidator: validator.NewUserValidator(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
*gin.Engine
|
||||
}
|
||||
|
||||
func generateServerID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
func (s Server) Listen(address string) error {
|
||||
fmt.Printf("API server listening at: %s\n\n", address)
|
||||
return s.Run(address)
|
||||
}
|
||||
|
||||
func (s Server) StartScheduler() {
|
||||
fmt.Printf("Scheduler started\n")
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package appcontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type key string
|
||||
|
||||
const (
|
||||
CorrelationIDKey = key("CorrelationID")
|
||||
OrganizationIDKey = key("OrganizationIDKey")
|
||||
UserIDKey = key("UserID")
|
||||
OutletIDKey = key("OutletID")
|
||||
RoleIDKey = key("RoleID")
|
||||
AppVersionKey = key("AppVersion")
|
||||
AppIDKey = key("AppID")
|
||||
AppTypeKey = key("AppType")
|
||||
PlatformKey = key("platform")
|
||||
DeviceOSKey = key("deviceOS")
|
||||
UserLocaleKey = key("userLocale")
|
||||
UserRoleKey = key("userRole")
|
||||
)
|
||||
|
||||
func LogFields(ctx interface{}) map[string]interface{} {
|
||||
fields := make(map[string]interface{})
|
||||
fields[string(CorrelationIDKey)] = value(ctx, CorrelationIDKey)
|
||||
fields[string(OrganizationIDKey)] = value(ctx, OrganizationIDKey)
|
||||
fields[string(OutletIDKey)] = value(ctx, OutletIDKey)
|
||||
fields[string(AppVersionKey)] = value(ctx, AppVersionKey)
|
||||
fields[string(AppIDKey)] = value(ctx, AppIDKey)
|
||||
fields[string(AppTypeKey)] = value(ctx, AppTypeKey)
|
||||
fields[string(UserIDKey)] = value(ctx, UserIDKey)
|
||||
fields[string(PlatformKey)] = value(ctx, PlatformKey)
|
||||
fields[string(DeviceOSKey)] = value(ctx, DeviceOSKey)
|
||||
fields[string(UserLocaleKey)] = value(ctx, UserLocaleKey)
|
||||
return fields
|
||||
}
|
||||
|
||||
func value(ctx interface{}, key key) string {
|
||||
switch c := ctx.(type) {
|
||||
case *gin.Context:
|
||||
return getFromGinContext(c, key)
|
||||
case context.Context:
|
||||
return getFromGoContext(c, key)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func uuidValue(ctx interface{}, key key) uuid.UUID {
|
||||
switch c := ctx.(type) {
|
||||
case *gin.Context:
|
||||
val, _ := uuid.Parse(getFromGinContext(c, key))
|
||||
return val
|
||||
case context.Context:
|
||||
val, _ := uuid.Parse(getFromGoContext(c, key))
|
||||
return val
|
||||
default:
|
||||
return uuid.New()
|
||||
}
|
||||
}
|
||||
|
||||
func getFromGinContext(c *gin.Context, key key) string {
|
||||
keyStr := string(key)
|
||||
if val, exists := c.Get(keyStr); exists {
|
||||
if str, ok := val.(string); ok {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return getFromGoContext(c.Request.Context(), key)
|
||||
}
|
||||
|
||||
func getFromGoContext(ctx context.Context, key key) string {
|
||||
if val, ok := ctx.Value(key).(string); ok {
|
||||
return val
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package appcontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type logCtxKeyType struct{}
|
||||
|
||||
var logCtxKey = logCtxKeyType(struct{}{})
|
||||
|
||||
type Logger struct {
|
||||
*logrus.Logger
|
||||
}
|
||||
|
||||
var log *Logger
|
||||
|
||||
type ContextInfo struct {
|
||||
CorrelationID string
|
||||
UserID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
OutletID uuid.UUID
|
||||
AppVersion string
|
||||
AppID string
|
||||
AppType string
|
||||
Platform string
|
||||
DeviceOS string
|
||||
UserLocale string
|
||||
UserRole string
|
||||
}
|
||||
|
||||
type ctxKeyType struct{}
|
||||
|
||||
var ctxKey = ctxKeyType(struct{}{})
|
||||
|
||||
func NewAppContext(ctx context.Context, info *ContextInfo) context.Context {
|
||||
ctx = NewContext(ctx, map[string]interface{}{
|
||||
"correlation_id": info.CorrelationID,
|
||||
"user_id": info.UserID,
|
||||
"app_version": info.AppVersion,
|
||||
"app_id": info.AppID,
|
||||
"app_type": info.AppType,
|
||||
"platform": info.Platform,
|
||||
"device_os": info.DeviceOS,
|
||||
"user_locale": info.UserLocale,
|
||||
})
|
||||
return context.WithValue(ctx, ctxKey, info)
|
||||
}
|
||||
|
||||
func NewContext(ctx context.Context, baseFields map[string]interface{}) context.Context {
|
||||
entry, ok := ctx.Value(logCtxKey).(*logrus.Entry)
|
||||
if !ok {
|
||||
entry = log.WithFields(map[string]interface{}{})
|
||||
}
|
||||
|
||||
return context.WithValue(ctx, logCtxKey, entry.WithFields(baseFields))
|
||||
}
|
||||
|
||||
func FromGinContext(ctx context.Context) *ContextInfo {
|
||||
return &ContextInfo{
|
||||
CorrelationID: value(ctx, CorrelationIDKey),
|
||||
UserID: uuidValue(ctx, UserIDKey),
|
||||
OutletID: uuidValue(ctx, OutletIDKey),
|
||||
OrganizationID: uuidValue(ctx, OrganizationIDKey),
|
||||
AppVersion: value(ctx, AppVersionKey),
|
||||
AppID: value(ctx, AppIDKey),
|
||||
AppType: value(ctx, AppTypeKey),
|
||||
Platform: value(ctx, PlatformKey),
|
||||
DeviceOS: value(ctx, DeviceOSKey),
|
||||
UserLocale: value(ctx, UserLocaleKey),
|
||||
UserRole: value(ctx, UserRoleKey),
|
||||
}
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) *ContextInfo {
|
||||
if info, ok := ctx.Value(ctxKey).(*ContextInfo); ok {
|
||||
return info
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
)
|
||||
|
||||
type FileConfig interface {
|
||||
GetAccessKeyID() string
|
||||
GetAccessKeySecret() string
|
||||
GetEndpoint() string
|
||||
GetBucketName() string
|
||||
GetHostURL() string
|
||||
}
|
||||
|
||||
const _awsRegion = "us-east-1"
|
||||
const _s3ACL = "public-read"
|
||||
|
||||
type S3FileClientImpl struct {
|
||||
s3 *s3.S3
|
||||
cfg FileConfig
|
||||
}
|
||||
|
||||
func NewFileClient(fileCfg FileConfig) *S3FileClientImpl {
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
Endpoint: aws.String(fileCfg.GetEndpoint()),
|
||||
Region: aws.String(_awsRegion),
|
||||
Credentials: credentials.NewStaticCredentials(fileCfg.GetAccessKeyID(), fileCfg.GetAccessKeySecret(), ""),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Failed to create AWS session:", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &S3FileClientImpl{
|
||||
s3: s3.New(sess),
|
||||
cfg: fileCfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *S3FileClientImpl) UploadFile(ctx context.Context, fileName string, fileContent []byte) (fileUrl string, err error) {
|
||||
return r.Upload(ctx, r.cfg.GetBucketName(), fileName, fileContent, "application/octet-stream")
|
||||
}
|
||||
|
||||
func (r *S3FileClientImpl) Upload(ctx context.Context, bucket, key string, content []byte, contentType string) (string, error) {
|
||||
reader := bytes.NewReader(content)
|
||||
_, err := r.s3.PutObjectWithContext(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
Body: reader,
|
||||
ACL: aws.String(_s3ACL),
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.GetPublicURL(bucket, key), nil
|
||||
}
|
||||
|
||||
// EnsureBucket ensures a bucket exists (idempotent)
|
||||
func (r *S3FileClientImpl) EnsureBucket(ctx context.Context, bucket string) error {
|
||||
_, err := r.s3.HeadBucketWithContext(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
_, err = r.s3.CreateBucketWithContext(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)})
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *S3FileClientImpl) GetPublicURL(bucket, key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
// HostURL expected to include scheme and optional host/path prefix; ensure single slash join
|
||||
return fmt.Sprintf("%s%s/%s", r.cfg.GetHostURL(), bucket, key)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package constants
|
||||
|
||||
const (
|
||||
RequestMethod = "RequestMethod"
|
||||
RequestPath = "RequestPath"
|
||||
RequestURLQueryParam = "RequestURLQueryParam"
|
||||
ResponseStatusCode = "ResponseStatusCode"
|
||||
ResponseStatusText = "ResponseStatusText"
|
||||
ResponseTimeTaken = "ResponseTimeTaken"
|
||||
)
|
||||
|
||||
var ValidCountryCodeMap = map[string]bool{
|
||||
"ID": true,
|
||||
"VI": true,
|
||||
"SG": true,
|
||||
"TH": true,
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package constants
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
InternalServerErrorCode = "900"
|
||||
MissingFieldErrorCode = "303"
|
||||
MalformedFieldErrorCode = "310"
|
||||
ValidationErrorCode = "304"
|
||||
InvalidFieldErrorCode = "305"
|
||||
NotFoundErrorCode = "404"
|
||||
)
|
||||
|
||||
const (
|
||||
RequestEntity = "request"
|
||||
UserServiceEntity = "user_service"
|
||||
OrganizationServiceEntity = "organization_service"
|
||||
CategoryServiceEntity = "category_service"
|
||||
ProductServiceEntity = "product_service"
|
||||
ProductVariantServiceEntity = "product_variant_service"
|
||||
InventoryServiceEntity = "inventory_service"
|
||||
OrderServiceEntity = "order_service"
|
||||
CustomerServiceEntity = "customer_service"
|
||||
UserValidatorEntity = "user_validator"
|
||||
AuthHandlerEntity = "auth_handler"
|
||||
UserHandlerEntity = "user_handler"
|
||||
CategoryHandlerEntity = "category_handler"
|
||||
ProductHandlerEntity = "product_handler"
|
||||
ProductVariantHandlerEntity = "product_variant_handler"
|
||||
InventoryHandlerEntity = "inventory_handler"
|
||||
OrderValidatorEntity = "order_validator"
|
||||
OrderHandlerEntity = "order_handler"
|
||||
OrganizationValidatorEntity = "organization_validator"
|
||||
OrgHandlerEntity = "organization_handler"
|
||||
PaymentMethodValidatorEntity = "payment_method_validator"
|
||||
PaymentMethodHandlerEntity = "payment_method_handler"
|
||||
OutletServiceEntity = "outlet_service"
|
||||
TableEntity = "table"
|
||||
)
|
||||
|
||||
var HttpErrorMap = map[string]int{
|
||||
InternalServerErrorCode: http.StatusInternalServerError,
|
||||
MissingFieldErrorCode: http.StatusBadRequest,
|
||||
MalformedFieldErrorCode: http.StatusBadRequest,
|
||||
ValidationErrorCode: http.StatusBadRequest,
|
||||
InvalidFieldErrorCode: http.StatusBadRequest,
|
||||
NotFoundErrorCode: http.StatusNotFound,
|
||||
}
|
||||
|
||||
// Error messages
|
||||
var (
|
||||
ErrPaymentMethodNameRequired = fmt.Errorf("payment method name is required")
|
||||
ErrPaymentMethodTypeRequired = fmt.Errorf("payment method type is required")
|
||||
ErrInvalidPaymentMethodType = fmt.Errorf("invalid payment method type")
|
||||
ErrInvalidPageNumber = fmt.Errorf("page number must be greater than 0")
|
||||
ErrInvalidLimit = fmt.Errorf("limit must be between 1 and 100")
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package constants
|
||||
|
||||
const (
|
||||
CorrelationIDHeader = "debug-id"
|
||||
XAppVersionHeader = "x-appversion"
|
||||
XDeviceOSHeader = "X-DeviceOS"
|
||||
XPlatformHeader = "X-Platform"
|
||||
XAppTypeHeader = "X-AppType"
|
||||
XAppIDHeader = "x-appid"
|
||||
XPhoneModelHeader = "X-PhoneModel"
|
||||
OrganizationID = "x_organization_id"
|
||||
OutletID = "x_owner_id"
|
||||
CountryCodeHeader = "country-code"
|
||||
AcceptedLanguageHeader = "accept-language"
|
||||
XUserLocaleHeader = "x-user-locale"
|
||||
LocaleHeader = "locale"
|
||||
GojekTimezoneHeader = "Gojek-Timezone"
|
||||
UserTypeHeader = "User-Type"
|
||||
AccountIDHeader = "Account-Id"
|
||||
GopayUserType = "gopay"
|
||||
XCorrelationIDHeader = "X-Correlation-Id"
|
||||
XRequestIDHeader = "X-Request-Id"
|
||||
XCountryCodeHeader = "X-Country-Code"
|
||||
XAppVersionHeaderPOP = "X-App-Version"
|
||||
XOwnerIDHeader = "X-Owner-Id"
|
||||
XAppIDHeaderPOP = "X-App-Id"
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package constants
|
||||
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleManager UserRole = "manager"
|
||||
RoleCashier UserRole = "cashier"
|
||||
RoleWaiter UserRole = "waiter"
|
||||
)
|
||||
|
||||
func GetAllUserRoles() []UserRole {
|
||||
return []UserRole{
|
||||
RoleAdmin,
|
||||
RoleManager,
|
||||
RoleCashier,
|
||||
RoleWaiter,
|
||||
}
|
||||
}
|
||||
|
||||
func IsValidUserRole(role UserRole) bool {
|
||||
for _, validRole := range GetAllUserRoles() {
|
||||
if role == validRole {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package contract
|
||||
|
||||
import "time"
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
type ValidationErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]string `json:"details"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
type SuccessResponse struct {
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type PaginationRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
}
|
||||
|
||||
type PaginationResponse struct {
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type SearchRequest struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
}
|
||||
|
||||
type DateRangeRequest struct {
|
||||
From *time.Time `json:"from,omitempty"`
|
||||
To *time.Time `json:"to,omitempty"`
|
||||
}
|
||||
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package contract
|
||||
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data"`
|
||||
Errors []*ResponseError `json:"errors"`
|
||||
}
|
||||
|
||||
func (r *Response) GetSuccess() bool {
|
||||
return r.Success
|
||||
}
|
||||
|
||||
func (r *Response) GetData() interface{} {
|
||||
return r.Data
|
||||
}
|
||||
|
||||
func (r *Response) GetErrors() []*ResponseError {
|
||||
return r.Errors
|
||||
}
|
||||
|
||||
func BuildSuccessResponse(data interface{}) *Response {
|
||||
return &Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
Errors: []*ResponseError(nil),
|
||||
}
|
||||
}
|
||||
|
||||
func BuildErrorResponse(errorList []*ResponseError) *Response {
|
||||
return &Response{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Errors: errorList,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Response) HasErrors() bool {
|
||||
return r.GetErrors() != nil && len(r.GetErrors()) > 0
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package contract
|
||||
|
||||
import "fmt"
|
||||
|
||||
type ResponseError struct {
|
||||
Code string `json:"code"`
|
||||
Entity string `json:"entity"`
|
||||
Cause string `json:"cause"`
|
||||
}
|
||||
|
||||
func NewResponseError(code, entity, cause string) *ResponseError {
|
||||
return &ResponseError{
|
||||
Code: code,
|
||||
Cause: cause,
|
||||
Entity: entity,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ResponseError) GetCode() string {
|
||||
return e.Code
|
||||
}
|
||||
|
||||
func (e *ResponseError) GetEntity() string {
|
||||
return e.Entity
|
||||
}
|
||||
|
||||
func (e *ResponseError) GetCause() string {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
func (e *ResponseError) Error() string {
|
||||
return fmt.Sprintf("%s: %s: %s", e.GetCode(), e.GetEntity(), e.GetCause())
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateUserRequest struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id" validate:"required"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"`
|
||||
Permissions map[string]interface{} `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Email *string `json:"email,omitempty" validate:"omitempty,email"`
|
||||
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Permissions *map[string]interface{} `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type ChangePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password" validate:"required"`
|
||||
NewPassword string `json:"new_password" validate:"required,min=6"`
|
||||
}
|
||||
|
||||
type UpdateUserOutletRequest struct {
|
||||
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
User UserResponse `json:"user"`
|
||||
Roles []RoleResponse `json:"roles"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Positions []PositionResponse `json:"positions"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListUsersRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
}
|
||||
|
||||
type ListUsersResponse struct {
|
||||
Users []UserResponse `json:"users"`
|
||||
Pagination PaginationResponse `json:"pagination"`
|
||||
}
|
||||
|
||||
type RoleResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type PositionResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type UserProfileResponse struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
FullName string `json:"full_name"`
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
JobTitle *string `json:"job_title,omitempty"`
|
||||
EmployeeNo *string `json:"employee_no,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
Timezone string `json:"timezone"`
|
||||
Locale string `json:"locale"`
|
||||
Preferences map[string]interface{} `json:"preferences"`
|
||||
NotificationPrefs map[string]interface{} `json:"notification_prefs"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UpdateUserProfileRequest struct {
|
||||
FullName *string `json:"full_name,omitempty"`
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
JobTitle *string `json:"job_title,omitempty"`
|
||||
EmployeeNo *string `json:"employee_no,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
Timezone *string `json:"timezone,omitempty"`
|
||||
Locale *string `json:"locale,omitempty"`
|
||||
Preferences *map[string]interface{} `json:"preferences,omitempty"`
|
||||
NotificationPrefs *map[string]interface{} `json:"notification_prefs,omitempty"`
|
||||
}
|
||||
|
||||
type TitleResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type ListTitlesResponse struct {
|
||||
Titles []TitleResponse `json:"titles"`
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"eslogad-be/config"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"go.uber.org/zap"
|
||||
_ "gopkg.in/yaml.v3"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewPostgres(c config.Database) (*gorm.DB, error) {
|
||||
dialector := postgres.New(postgres.Config{
|
||||
DSN: c.DSN(),
|
||||
})
|
||||
|
||||
db, err := gorm.Open(dialector, &gorm.Config{})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zapCfg := zap.NewProductionConfig()
|
||||
zapCfg.Level = zap.NewAtomicLevelAt(zap.ErrorLevel)
|
||||
zapCfg.DisableCaller = false
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sqlDB.SetMaxIdleConns(c.MaxIdleConnectionsInSecond)
|
||||
sqlDB.SetMaxOpenConns(c.MaxOpenConnectionsInSecond)
|
||||
sqlDB.SetConnMaxLifetime(c.ConnectionMaxLifetime())
|
||||
|
||||
fmt.Println("Successfully connected to PostgreSQL database")
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func runMigrations(sqlDB *gorm.DB) error {
|
||||
// use the underlying *sql.DB for Exec
|
||||
db := sqlDB
|
||||
sqlConn, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrationsDir := "migrations"
|
||||
entries := []string{}
|
||||
if err := filepath.WalkDir(migrationsDir, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(d.Name()) == ".sql" {
|
||||
entries = append(entries, path)
|
||||
}
|
||||
return nil
|
||||
}); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// sort by name to ensure order
|
||||
sort.Strings(entries)
|
||||
|
||||
for _, file := range entries {
|
||||
contents, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", file, err)
|
||||
}
|
||||
if _, err := sqlConn.Exec(string(contents)); err != nil {
|
||||
return fmt.Errorf("exec migration %s: %w", file, err)
|
||||
}
|
||||
fmt.Printf("Applied migration: %s\n", filepath.Base(file))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Role struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Code string `gorm:"uniqueIndex;not null" json:"code"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Role) TableName() string { return "roles" }
|
||||
|
||||
type Permission struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Code string `gorm:"uniqueIndex;not null" json:"code"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Permission) TableName() string { return "permissions" }
|
||||
|
||||
type Position struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Code string `gorm:"uniqueIndex" json:"code"`
|
||||
Path string `gorm:"type:ltree;uniqueIndex" json:"path"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Position) TableName() string { return "positions" }
|
||||
@@ -0,0 +1,18 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Title struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Code *string `gorm:"uniqueIndex" json:"code,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Title) TableName() string { return "titles" }
|
||||
@@ -0,0 +1,70 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleManager UserRole = "manager"
|
||||
RoleCashier UserRole = "cashier"
|
||||
RoleWaiter UserRole = "waiter"
|
||||
)
|
||||
|
||||
type Permissions map[string]interface{}
|
||||
|
||||
func (p Permissions) Value() (driver.Value, error) {
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
func (p *Permissions) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*p = make(Permissions)
|
||||
return nil
|
||||
}
|
||||
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return errors.New("type assertion to []byte failed")
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, p)
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||
Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"`
|
||||
PasswordHash string `gorm:"not null;size:255" json:"-"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (u *User) BeforeCreate(tx *gorm.DB) error {
|
||||
if u.ID == uuid.Nil {
|
||||
u.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
|
||||
func (u *User) HasPermission(permission string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (u *User) CanAccessOutlet(outletID uuid.UUID) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type JSONB map[string]interface{}
|
||||
|
||||
func (j JSONB) Value() (driver.Value, error) {
|
||||
return json.Marshal(j)
|
||||
}
|
||||
|
||||
func (j *JSONB) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*j = make(JSONB)
|
||||
return nil
|
||||
}
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(bytes, j)
|
||||
}
|
||||
|
||||
type UserProfile struct {
|
||||
UserID uuid.UUID `gorm:"type:uuid;primaryKey" json:"user_id"`
|
||||
FullName string `gorm:"not null;size:150" json:"full_name"`
|
||||
DisplayName *string `gorm:"size:100" json:"display_name,omitempty"`
|
||||
Phone *string `gorm:"size:50" json:"phone,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
JobTitle *string `gorm:"size:120" json:"job_title,omitempty"`
|
||||
EmployeeNo *string `gorm:"size:60" json:"employee_no,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
Timezone string `gorm:"size:64;default:Asia/Jakarta" json:"timezone"`
|
||||
Locale string `gorm:"size:16;default:id-ID" json:"locale"`
|
||||
Preferences JSONB `gorm:"type:jsonb;default:'{}'" json:"preferences"`
|
||||
NotificationPrefs JSONB `gorm:"type:jsonb;default:'{}'" json:"notification_prefs"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (UserProfile) TableName() string { return "user_profiles" }
|
||||
@@ -0,0 +1,168 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"eslogad-be/internal/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"eslogad-be/internal/constants"
|
||||
"eslogad-be/internal/contract"
|
||||
"eslogad-be/internal/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService AuthService
|
||||
}
|
||||
|
||||
func NewAuthHandler(authService AuthService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req contract.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::Login -> request binding failed")
|
||||
h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Email) == "" {
|
||||
logger.FromContext(c.Request.Context()).Error("AuthHandler::Login -> email is required")
|
||||
h.sendValidationErrorResponse(c, "Email is required", constants.MissingFieldErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Password) == "" {
|
||||
logger.FromContext(c.Request.Context()).Error("AuthHandler::Login -> password is required")
|
||||
h.sendValidationErrorResponse(c, "Password is required", constants.MissingFieldErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
loginResponse, err := h.authService.Login(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::Login -> Failed to login")
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
logger.FromContext(c.Request.Context()).Infof("AuthHandler::Login -> Successfully logged in user = %s", loginResponse.User.Email)
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(loginResponse), "AuthHandler::Login")
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
token := h.extractTokenFromHeader(c)
|
||||
if token == "" {
|
||||
logger.FromContext(c.Request.Context()).Error("AuthHandler::Logout -> token is required")
|
||||
h.sendErrorResponse(c, "Token is required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.authService.Logout(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::Logout -> Failed to logout")
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
logger.FromContext(c.Request.Context()).Info("AuthHandler::Logout -> Successfully logged out")
|
||||
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "Successfully logged out"})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
token := h.extractTokenFromHeader(c)
|
||||
if token == "" {
|
||||
logger.FromContext(c.Request.Context()).Error("AuthHandler::RefreshToken -> token is required")
|
||||
h.sendErrorResponse(c, "Token is required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
loginResponse, err := h.authService.RefreshToken(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::RefreshToken -> Failed to refresh token")
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
logger.FromContext(c.Request.Context()).Infof("AuthHandler::RefreshToken -> Successfully refreshed token for user = %s", loginResponse.User.Email)
|
||||
c.JSON(http.StatusOK, loginResponse)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ValidateToken(c *gin.Context) {
|
||||
token := h.extractTokenFromHeader(c)
|
||||
if token == "" {
|
||||
logger.FromContext(c.Request.Context()).Error("AuthHandler::ValidateToken -> token is required")
|
||||
h.sendErrorResponse(c, "Token is required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userResponse, err := h.authService.ValidateToken(token)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::ValidateToken -> Failed to validate token")
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
logger.FromContext(c.Request.Context()).Infof("AuthHandler::ValidateToken -> Successfully validated token for user = %s", userResponse.Email)
|
||||
c.JSON(http.StatusOK, userResponse)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetProfile(c *gin.Context) {
|
||||
token := h.extractTokenFromHeader(c)
|
||||
if token == "" {
|
||||
logger.FromContext(c.Request.Context()).Error("AuthHandler::GetProfile -> token is required")
|
||||
h.sendErrorResponse(c, "Token is required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userResponse, err := h.authService.ValidateToken(token)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::GetProfile -> Failed to get profile")
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
logger.FromContext(c.Request.Context()).Infof("AuthHandler::GetProfile -> Successfully retrieved profile for user = %s", userResponse.Email)
|
||||
c.JSON(http.StatusOK, &contract.SuccessResponse{Data: userResponse, Message: "success get user profile"})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) extractTokenFromHeader(c *gin.Context) string {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Expected format: "Bearer <token>"
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
func (h *AuthHandler) sendErrorResponse(c *gin.Context, message string, statusCode int) {
|
||||
errorResponse := &contract.ErrorResponse{
|
||||
Error: "error",
|
||||
Message: message,
|
||||
Code: statusCode,
|
||||
}
|
||||
c.JSON(statusCode, errorResponse)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) sendValidationErrorResponse(c *gin.Context, message string, errorCode string) {
|
||||
errorResponse := &contract.ErrorResponse{
|
||||
Error: "validation_error",
|
||||
Message: message,
|
||||
Code: http.StatusBadRequest,
|
||||
Details: map[string]interface{}{
|
||||
"error_code": errorCode,
|
||||
"entity": constants.AuthHandlerEntity,
|
||||
},
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, errorResponse)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"eslogad-be/internal/contract"
|
||||
)
|
||||
|
||||
type AuthService interface {
|
||||
Login(ctx context.Context, req *contract.LoginRequest) (*contract.LoginResponse, error)
|
||||
ValidateToken(tokenString string) (*contract.UserResponse, error)
|
||||
RefreshToken(ctx context.Context, tokenString string) (*contract.LoginResponse, error)
|
||||
Logout(ctx context.Context, tokenString string) error
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CommonMiddleware struct{}
|
||||
|
||||
func NewCommonMiddleware() *CommonMiddleware {
|
||||
return &CommonMiddleware{}
|
||||
}
|
||||
|
||||
func (m *CommonMiddleware) CORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *CommonMiddleware) ContentType(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *CommonMiddleware) Logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
_ = time.Since(start)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *CommonMiddleware) Recovery(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"eslogad-be/internal/appcontext"
|
||||
"eslogad-be/internal/contract"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type FileService interface {
|
||||
UploadProfileAvatar(ctx context.Context, userID uuid.UUID, filename string, content []byte, contentType string) (string, error)
|
||||
UploadDocument(ctx context.Context, userID uuid.UUID, filename string, content []byte, contentType string) (string, string, error)
|
||||
}
|
||||
|
||||
type FileHandler struct {
|
||||
service FileService
|
||||
}
|
||||
|
||||
func NewFileHandler(service FileService) *FileHandler {
|
||||
return &FileHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *FileHandler) UploadProfileAvatar(c *gin.Context) {
|
||||
appCtx := appcontext.FromGinContext(c.Request.Context())
|
||||
if appCtx.UserID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, &contract.ErrorResponse{Error: "Unauthorized", Code: http.StatusUnauthorized})
|
||||
return
|
||||
}
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "file is required", Code: http.StatusBadRequest})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(io.LimitReader(file, 10<<20))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "failed to read file", Code: http.StatusBadRequest})
|
||||
return
|
||||
}
|
||||
ct := header.Header.Get("Content-Type")
|
||||
url, err := h.service.UploadProfileAvatar(c.Request.Context(), appCtx.UserID, header.Filename, content, ct)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]string{"url": url}))
|
||||
}
|
||||
|
||||
func (h *FileHandler) UploadDocument(c *gin.Context) {
|
||||
appCtx := appcontext.FromGinContext(c.Request.Context())
|
||||
if appCtx.UserID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, &contract.ErrorResponse{Error: "Unauthorized", Code: http.StatusUnauthorized})
|
||||
return
|
||||
}
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "file is required", Code: http.StatusBadRequest})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
content, err := io.ReadAll(io.LimitReader(file, 20<<20))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "failed to read file", Code: http.StatusBadRequest})
|
||||
return
|
||||
}
|
||||
ct := header.Header.Get("Content-Type")
|
||||
url, key, err := h.service.UploadDocument(c.Request.Context(), appCtx.UserID, header.Filename, content, ct)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]string{"url": url, "key": key}))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"eslogad-be/internal/logger"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type HealthHandler struct {
|
||||
}
|
||||
|
||||
func NewHealthHandler() *HealthHandler {
|
||||
return &HealthHandler{}
|
||||
}
|
||||
|
||||
func (hh *HealthHandler) HealthCheck(c *gin.Context) {
|
||||
log := logger.NewContextLogger(c, "healthCheck")
|
||||
log.Info("Health Check success")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "Healthy!!",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"eslogad-be/internal/appcontext"
|
||||
"eslogad-be/internal/constants"
|
||||
"eslogad-be/internal/contract"
|
||||
"eslogad-be/internal/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
userService UserService
|
||||
userValidator UserValidator
|
||||
}
|
||||
|
||||
func NewUserHandler(userService UserService, userValidator UserValidator) *UserHandler {
|
||||
return &UserHandler{
|
||||
userService: userService,
|
||||
userValidator: userValidator,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||
var req contract.CreateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::CreateUser -> request binding failed")
|
||||
h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.userValidator.ValidateCreateUserRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c).WithError(validationError).Error("UserHandler::CreateUser -> request validation failed")
|
||||
h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
userResponse, err := h.userService.CreateUser(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::CreateUser -> Failed to create user from service")
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.FromContext(c).Infof("UserHandler::CreateUser -> Successfully created user = %+v", userResponse)
|
||||
c.JSON(http.StatusCreated, userResponse)
|
||||
}
|
||||
|
||||
func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
userIDStr := c.Param("id")
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::UpdateUser -> Invalid user ID")
|
||||
h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.userValidator.ValidateUserID(userID)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c).WithError(validationError).Error("UserHandler::UpdateUser -> user ID validation failed")
|
||||
h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::UpdateUser -> request binding failed")
|
||||
h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode = h.userValidator.ValidateUpdateUserRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c).WithError(validationError).Error("UserHandler::UpdateUser -> request validation failed")
|
||||
h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
userResponse, err := h.userService.UpdateUser(c.Request.Context(), userID, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::UpdateUser -> Failed to update user from service")
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.FromContext(c).Infof("UserHandler::UpdateUser -> Successfully updated user = %+v", userResponse)
|
||||
c.JSON(http.StatusOK, userResponse)
|
||||
}
|
||||
|
||||
func (h *UserHandler) DeleteUser(c *gin.Context) {
|
||||
userIDStr := c.Param("id")
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::DeleteUser -> Invalid user ID")
|
||||
h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.userValidator.ValidateUserID(userID)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c).WithError(validationError).Error("UserHandler::DeleteUser -> user ID validation failed")
|
||||
h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.userService.DeleteUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::DeleteUser -> Failed to delete user from service")
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.FromContext(c).Info("UserHandler::DeleteUser -> Successfully deleted user")
|
||||
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "User deleted successfully"})
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetUser(c *gin.Context) {
|
||||
userIDStr := c.Param("id")
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::GetUser -> Invalid user ID")
|
||||
h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.userValidator.ValidateUserID(userID)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c).WithError(validationError).Error("UserHandler::GetUser -> user ID validation failed")
|
||||
h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
userResponse, err := h.userService.GetUserByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::GetUser -> Failed to get user from service")
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.FromContext(c).Infof("UserHandler::GetUser -> Successfully retrieved user = %+v", userResponse)
|
||||
c.JSON(http.StatusOK, userResponse)
|
||||
}
|
||||
|
||||
func (h *UserHandler) ListUsers(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
req := &contract.ListUsersRequest{
|
||||
Page: 1,
|
||||
Limit: 10,
|
||||
}
|
||||
|
||||
if page := c.Query("page"); page != "" {
|
||||
if p, err := strconv.Atoi(page); err == nil {
|
||||
req.Page = p
|
||||
}
|
||||
}
|
||||
|
||||
if limit := c.Query("limit"); limit != "" {
|
||||
if l, err := strconv.Atoi(limit); err == nil {
|
||||
req.Limit = l
|
||||
}
|
||||
}
|
||||
|
||||
if role := c.Query("role"); role != "" {
|
||||
req.Role = &role
|
||||
}
|
||||
|
||||
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
|
||||
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
|
||||
req.IsActive = &isActive
|
||||
}
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.userValidator.ValidateListUsersRequest(req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c).WithError(validationError).Error("UserHandler::ListUsers -> request validation failed")
|
||||
h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
usersResponse, err := h.userService.ListUsers(ctx, req)
|
||||
if err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::ListUsers -> Failed to list users from service")
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.FromContext(c).Infof("UserHandler::ListUsers -> Successfully listed users = %+v", usersResponse)
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(usersResponse))
|
||||
}
|
||||
|
||||
func (h *UserHandler) ChangePassword(c *gin.Context) {
|
||||
userIDStr := c.Param("id")
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::ChangePassword -> Invalid user ID")
|
||||
h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.userValidator.ValidateUserID(userID)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c).WithError(validationError).Error("UserHandler::ChangePassword -> user ID validation failed")
|
||||
h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.ChangePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::ChangePassword -> request binding failed")
|
||||
h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode = h.userValidator.ValidateChangePasswordRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c).WithError(validationError).Error("UserHandler::ChangePassword -> request validation failed")
|
||||
h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.userService.ChangePassword(c.Request.Context(), userID, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::ChangePassword -> Failed to change password from service")
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.FromContext(c).Info("UserHandler::ChangePassword -> Successfully changed password")
|
||||
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "Password changed successfully"})
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
appCtx := appcontext.FromGinContext(c.Request.Context())
|
||||
if appCtx.UserID == uuid.Nil {
|
||||
h.sendErrorResponse(c, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
profile, err := h.userService.GetProfile(c.Request.Context(), appCtx.UserID)
|
||||
if err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::GetProfile -> Failed to get profile")
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(profile))
|
||||
}
|
||||
|
||||
func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
appCtx := appcontext.FromGinContext(c.Request.Context())
|
||||
if appCtx.UserID == uuid.Nil {
|
||||
h.sendErrorResponse(c, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req contract.UpdateUserProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode)
|
||||
return
|
||||
}
|
||||
updated, err := h.userService.UpdateProfile(c.Request.Context(), appCtx.UserID, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::UpdateProfile -> Failed to update profile")
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(updated))
|
||||
}
|
||||
|
||||
func (h *UserHandler) ListTitles(c *gin.Context) {
|
||||
resp, err := h.userService.ListTitles(c.Request.Context())
|
||||
if err != nil {
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
|
||||
}
|
||||
|
||||
func (h *UserHandler) sendErrorResponse(c *gin.Context, message string, statusCode int) {
|
||||
errorResponse := &contract.ErrorResponse{
|
||||
Error: message,
|
||||
Code: statusCode,
|
||||
Details: map[string]interface{}{},
|
||||
}
|
||||
c.JSON(statusCode, errorResponse)
|
||||
}
|
||||
|
||||
func (h *UserHandler) sendValidationErrorResponse(c *gin.Context, message string, errorCode string) {
|
||||
statusCode := constants.HttpErrorMap[errorCode]
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusBadRequest
|
||||
}
|
||||
|
||||
errorResponse := &contract.ErrorResponse{
|
||||
Error: message,
|
||||
Code: statusCode,
|
||||
Details: map[string]interface{}{
|
||||
"error_code": errorCode,
|
||||
"entity": constants.UserValidatorEntity,
|
||||
},
|
||||
}
|
||||
c.JSON(statusCode, errorResponse)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"eslogad-be/internal/contract"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserService interface {
|
||||
CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error)
|
||||
UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error)
|
||||
DeleteUser(ctx context.Context, id uuid.UUID) error
|
||||
GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error)
|
||||
ListUsers(ctx context.Context, req *contract.ListUsersRequest) (*contract.ListUsersResponse, error)
|
||||
ChangePassword(ctx context.Context, userID uuid.UUID, req *contract.ChangePasswordRequest) error
|
||||
|
||||
GetProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error)
|
||||
UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error)
|
||||
|
||||
ListTitles(ctx context.Context) (*contract.ListTitlesResponse, error)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"eslogad-be/internal/contract"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserValidator interface {
|
||||
ValidateCreateUserRequest(req *contract.CreateUserRequest) (error, string)
|
||||
ValidateUpdateUserRequest(req *contract.UpdateUserRequest) (error, string)
|
||||
ValidateListUsersRequest(req *contract.ListUsersRequest) (error, string)
|
||||
ValidateChangePasswordRequest(req *contract.ChangePasswordRequest) (error, string)
|
||||
ValidateUserID(userID uuid.UUID) (error, string)
|
||||
ValidateUpdateUserOutletRequest(req *contract.UpdateUserOutletRequest) (error, string)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"eslogad-be/internal/appcontext"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type logCtxKeyType struct{}
|
||||
|
||||
var logCtxKey = logCtxKeyType(struct{}{})
|
||||
|
||||
var logger *logrus.Logger
|
||||
|
||||
const (
|
||||
LogMethod = "Method"
|
||||
LogError = "Error"
|
||||
)
|
||||
|
||||
func Setup(logLevel, logFormat string) {
|
||||
level, err := logrus.ParseLevel(logLevel)
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
|
||||
logger = &logrus.Logger{
|
||||
Out: os.Stdout,
|
||||
Hooks: make(logrus.LevelHooks),
|
||||
Level: level,
|
||||
Formatter: &logrus.JSONFormatter{},
|
||||
}
|
||||
|
||||
if logFormat != "json" {
|
||||
logger.Formatter = &logrus.TextFormatter{}
|
||||
}
|
||||
NonContext = &ContextLogger{
|
||||
entry: logrus.NewEntry(logger),
|
||||
}
|
||||
}
|
||||
|
||||
type ContextLogger struct {
|
||||
entry *logrus.Entry
|
||||
}
|
||||
|
||||
var NonContext *ContextLogger
|
||||
|
||||
func NewContextLogger(ctx interface{}, method string) *ContextLogger {
|
||||
logEntry := logger.WithFields(appcontext.LogFields(ctx)).WithField(LogMethod, method)
|
||||
return &ContextLogger{
|
||||
entry: logEntry,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ContextLogger) Fatal(errMessage string, err error) {
|
||||
l.entry.
|
||||
WithField(LogError, err).
|
||||
Fatal(errMessage)
|
||||
}
|
||||
|
||||
func (l *ContextLogger) Error(errMessage string, err error) {
|
||||
l.entry.WithField(LogError, err).Error(errMessage)
|
||||
}
|
||||
|
||||
func (l *ContextLogger) Errorf(err error, errMessageFormat string, errMessages ...interface{}) {
|
||||
l.entry.WithField(LogError, err).Errorf(errMessageFormat, errMessages...)
|
||||
}
|
||||
|
||||
func (l *ContextLogger) ErrorWithFields(msg string, fields map[string]interface{}, err error) {
|
||||
for key, val := range fields {
|
||||
l.entry = l.entry.WithField(key, val)
|
||||
}
|
||||
|
||||
l.entry.WithField(LogError, err).Error(msg)
|
||||
}
|
||||
|
||||
func (l *ContextLogger) Info(msg string) {
|
||||
l.entry.Info(msg)
|
||||
|
||||
}
|
||||
|
||||
func (l *ContextLogger) Infof(msg string, args ...interface{}) {
|
||||
l.entry.Infof(msg, args...)
|
||||
}
|
||||
|
||||
func (l *ContextLogger) Debugf(msg string, args ...interface{}) {
|
||||
l.entry.Debugf(msg, args...)
|
||||
}
|
||||
|
||||
func (l *ContextLogger) Debug(msg string) {
|
||||
l.entry.Debug(msg)
|
||||
}
|
||||
|
||||
func (l *ContextLogger) InfoWithFields(msg string, fields map[string]interface{}) {
|
||||
for key, val := range fields {
|
||||
l.entry = l.entry.WithField(key, val)
|
||||
}
|
||||
|
||||
l.entry.Info(msg)
|
||||
}
|
||||
|
||||
func (l *ContextLogger) DebugWithFields(msg string, fields map[string]interface{}) {
|
||||
for key, val := range fields {
|
||||
l.entry = l.entry.WithField(key, val)
|
||||
}
|
||||
|
||||
l.entry.Debug(msg)
|
||||
}
|
||||
|
||||
func (l *ContextLogger) Warn(msg string) {
|
||||
l.entry.Warn(msg)
|
||||
}
|
||||
|
||||
func (l *ContextLogger) Warnf(msg string, args ...interface{}) {
|
||||
l.entry.Warnf(msg, args...)
|
||||
}
|
||||
|
||||
func (l *ContextLogger) WarnWithFields(msg string, fields map[string]interface{}, err error) {
|
||||
for key, val := range fields {
|
||||
l.entry = l.entry.WithField(key, val)
|
||||
}
|
||||
|
||||
l.entry.WithField(LogError, err)
|
||||
l.entry.Warn(msg)
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) *logrus.Entry {
|
||||
if entry, ok := ctx.Value(logCtxKey).(*logrus.Entry); ok {
|
||||
return entry
|
||||
}
|
||||
return logger.WithFields(map[string]interface{}{})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"eslogad-be/internal/appcontext"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"eslogad-be/internal/constants"
|
||||
"eslogad-be/internal/contract"
|
||||
"eslogad-be/internal/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthMiddleware struct {
|
||||
authService AuthValidateService
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(authService AuthValidateService) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := m.extractTokenFromHeader(c)
|
||||
if token == "" {
|
||||
logger.FromContext(c.Request.Context()).Error("AuthMiddleware::RequireAuth -> Missing authorization token")
|
||||
m.sendErrorResponse(c, "Authorization token is required", http.StatusUnauthorized)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userResponse, err := m.authService.ValidateToken(token)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("AuthMiddleware::RequireAuth -> Invalid token")
|
||||
m.sendErrorResponse(c, "Invalid or expired token", http.StatusUnauthorized)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String())
|
||||
|
||||
if roles, perms, err := m.authService.ExtractAccess(token); err == nil {
|
||||
c.Set("user_roles", roles)
|
||||
c.Set("user_permissions", perms)
|
||||
}
|
||||
|
||||
logger.FromContext(c.Request.Context()).Infof("AuthMiddleware::RequireAuth -> User authenticated: %s", userResponse.Email)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
appCtx := appcontext.FromGinContext(c.Request.Context())
|
||||
|
||||
hasRequiredRole := false
|
||||
for _, role := range allowedRoles {
|
||||
if appCtx.UserRole == role {
|
||||
hasRequiredRole = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasRequiredRole {
|
||||
m.sendErrorResponse(c, "Insufficient permissions", http.StatusForbidden)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
||||
return m.RequireRole("admin", "manager")
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
||||
return m.RequireRole("admin")
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireSuperAdmin() gin.HandlerFunc {
|
||||
return m.RequireRole("superadmin")
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireActiveUser() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userResponse, exists := c.Get("user")
|
||||
if !exists {
|
||||
logger.FromContext(c.Request.Context()).Error("AuthMiddleware::RequireActiveUser -> User not authenticated")
|
||||
m.sendErrorResponse(c, "Authentication required", http.StatusUnauthorized)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := userResponse.(*contract.UserResponse)
|
||||
if !ok {
|
||||
logger.FromContext(c.Request.Context()).Error("AuthMiddleware::RequireActiveUser -> Invalid user context")
|
||||
m.sendErrorResponse(c, "Invalid user context", http.StatusInternalServerError)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
logger.FromContext(c.Request.Context()).Errorf("AuthMiddleware::RequireActiveUser -> User account is deactivated: %s", user.Email)
|
||||
m.sendErrorResponse(c, "User account is deactivated", http.StatusForbidden)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
logger.FromContext(c.Request.Context()).Infof("AuthMiddleware::RequireActiveUser -> Active user check passed: %s", user.Email)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequirePermissions(required ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, exists := c.Get("user_permissions"); !exists {
|
||||
m.sendErrorResponse(c, "Authentication required", http.StatusUnauthorized)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
permIface, _ := c.Get("user_permissions")
|
||||
perms, _ := permIface.([]string)
|
||||
userPerms := map[string]bool{}
|
||||
for _, code := range perms {
|
||||
userPerms[code] = true
|
||||
}
|
||||
|
||||
for _, need := range required {
|
||||
if !userPerms[need] {
|
||||
m.sendErrorResponse(c, "Insufficient permissions", http.StatusForbidden)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) extractTokenFromHeader(c *gin.Context) string {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) sendErrorResponse(c *gin.Context, message string, statusCode int) {
|
||||
errorResponse := &contract.ErrorResponse{
|
||||
Error: "auth_error",
|
||||
Message: message,
|
||||
Code: statusCode,
|
||||
Details: map[string]interface{}{
|
||||
"entity": constants.AuthHandlerEntity,
|
||||
},
|
||||
}
|
||||
c.JSON(statusCode, errorResponse)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package middleware
|
||||
|
||||
type AuthProcessor interface {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"eslogad-be/internal/contract"
|
||||
)
|
||||
|
||||
type AuthValidateService interface {
|
||||
ValidateToken(tokenString string) (*contract.UserResponse, error)
|
||||
RefreshToken(ctx context.Context, tokenString string) (*contract.LoginResponse, error)
|
||||
Logout(ctx context.Context, tokenString string) error
|
||||
ExtractAccess(tokenString string) (roles []string, permissions []string, err error)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"eslogad-be/internal/appcontext"
|
||||
"eslogad-be/internal/constants"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func PopulateContext() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
setKeyInContext(c, appcontext.AppIDKey, getAppID(c))
|
||||
setKeyInContext(c, appcontext.AppVersionKey, getAppVersion(c))
|
||||
setKeyInContext(c, appcontext.AppTypeKey, getAppType(c))
|
||||
setKeyInContext(c, appcontext.OrganizationIDKey, getOrganizationID(c))
|
||||
setKeyInContext(c, appcontext.OutletIDKey, getOutletID(c))
|
||||
setKeyInContext(c, appcontext.DeviceOSKey, getDeviceOS(c))
|
||||
setKeyInContext(c, appcontext.PlatformKey, getDevicePlatform(c))
|
||||
setKeyInContext(c, appcontext.UserLocaleKey, getUserLocale(c))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func getAppID(c *gin.Context) string {
|
||||
return c.GetHeader(constants.XAppIDHeader)
|
||||
}
|
||||
|
||||
func getAppType(c *gin.Context) string {
|
||||
return c.GetHeader(constants.XAppTypeHeader)
|
||||
}
|
||||
|
||||
func getAppVersion(c *gin.Context) string {
|
||||
return c.GetHeader(constants.XAppVersionHeader)
|
||||
}
|
||||
|
||||
func getOrganizationID(c *gin.Context) string {
|
||||
return c.GetHeader(constants.OrganizationID)
|
||||
}
|
||||
|
||||
func getOutletID(c *gin.Context) string {
|
||||
return c.GetHeader(constants.OutletID)
|
||||
}
|
||||
|
||||
func getDeviceOS(c *gin.Context) string {
|
||||
return c.GetHeader(constants.XDeviceOSHeader)
|
||||
}
|
||||
|
||||
func getDevicePlatform(c *gin.Context) string {
|
||||
return c.GetHeader(constants.XPlatformHeader)
|
||||
}
|
||||
|
||||
func getUserLocale(c *gin.Context) string {
|
||||
userLocale := c.GetHeader(constants.XUserLocaleHeader)
|
||||
if userLocale == "" {
|
||||
userLocale = c.GetHeader(constants.AcceptedLanguageHeader)
|
||||
}
|
||||
if userLocale == "" {
|
||||
userLocale = c.GetHeader(constants.LocaleHeader)
|
||||
}
|
||||
return userLocale
|
||||
}
|
||||
|
||||
func setKeyInContext(c *gin.Context, contextKey interface{}, contextKeyValue string) {
|
||||
ctx := context.WithValue(c.Request.Context(),
|
||||
contextKey, contextKeyValue)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"eslogad-be/internal/appcontext"
|
||||
"eslogad-be/internal/constants"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func CorrelationID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
correlationID := c.GetHeader(constants.CorrelationIDHeader)
|
||||
if correlationID == "" {
|
||||
correlationID = uuid.New().String()
|
||||
}
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.CorrelationIDKey, correlationID)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Writer.Header().Set(constants.CorrelationIDHeader, correlationID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func CORS() gin.HandlerFunc {
|
||||
return gin.HandlerFunc(func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
contentTypeHeader = "Content-Type"
|
||||
jsonContentType = "application/json"
|
||||
)
|
||||
|
||||
func JsonAPI() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Writer.Header().Set(contentTypeHeader, jsonContentType)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Logging() gin.HandlerFunc {
|
||||
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||||
return fmt.Sprintf("%s - [%s] \"%s %s %s %d %s \"%s\" %s\"\n",
|
||||
param.ClientIP,
|
||||
param.TimeStamp.Format(time.RFC1123),
|
||||
param.Method,
|
||||
param.Path,
|
||||
param.Request.Proto,
|
||||
param.StatusCode,
|
||||
param.Latency,
|
||||
param.Request.UserAgent(),
|
||||
param.ErrorMessage,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type RateLimiter struct {
|
||||
requests map[string][]time.Time
|
||||
mutex sync.RWMutex
|
||||
limit int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
requests: make(map[string][]time.Time),
|
||||
limit: limit,
|
||||
window: window,
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) Allow(key string) bool {
|
||||
rl.mutex.Lock()
|
||||
defer rl.mutex.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-rl.window)
|
||||
|
||||
// Clean old requests
|
||||
if times, exists := rl.requests[key]; exists {
|
||||
var validTimes []time.Time
|
||||
for _, t := range times {
|
||||
if t.After(windowStart) {
|
||||
validTimes = append(validTimes, t)
|
||||
}
|
||||
}
|
||||
rl.requests[key] = validTimes
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if len(rl.requests[key]) >= rl.limit {
|
||||
return false
|
||||
}
|
||||
|
||||
// Add current request
|
||||
rl.requests[key] = append(rl.requests[key], now)
|
||||
return true
|
||||
}
|
||||
|
||||
func RateLimit() gin.HandlerFunc {
|
||||
limiter := NewRateLimiter(100, time.Minute) // 100 requests per minute
|
||||
|
||||
return gin.HandlerFunc(func(c *gin.Context) {
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
if !limiter.Allow(clientIP) {
|
||||
c.JSON(429, gin.H{
|
||||
"error": "Rate limit exceeded",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"eslogad-be/internal/contract"
|
||||
"eslogad-be/internal/logger"
|
||||
"eslogad-be/internal/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
func Recover() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
logger.NonContext.Errorf(nil, "Recovered from panic %v", map[string]interface{}{
|
||||
"stack_trace": string(debug.Stack()),
|
||||
"error": err,
|
||||
})
|
||||
debug.PrintStack()
|
||||
errorResponse := contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError("900", "", string(debug.Stack())),
|
||||
})
|
||||
util.WriteResponse(c.Writer, c.Request, *errorResponse, http.StatusInternalServerError, "Middleware::Recover")
|
||||
c.Abort()
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"eslogad-be/internal/constants"
|
||||
"eslogad-be/internal/logger"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func HTTPStatLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.URL.Path == "/health" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
duration := time.Since(start)
|
||||
|
||||
status := c.Writer.Status()
|
||||
|
||||
log := logger.NewContextLogger(c, "HTTPStatLogger")
|
||||
log.Infof("CompletedHTTPRequest %v", map[string]string{
|
||||
constants.RequestMethod: c.Request.Method,
|
||||
constants.RequestPath: c.Request.URL.Path,
|
||||
constants.RequestURLQueryParam: c.Request.URL.RawQuery,
|
||||
constants.ResponseStatusCode: fmt.Sprintf("%d", status),
|
||||
constants.ResponseStatusText: http.StatusText(status),
|
||||
constants.ResponseTimeTaken: fmt.Sprintf("%f", duration.Seconds()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"eslogad-be/internal/contract"
|
||||
"eslogad-be/internal/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserIDResolver struct {
|
||||
userProcessor UserProcessor
|
||||
authProcessor AuthProcessor
|
||||
}
|
||||
|
||||
func NewUserIDResolver(userProcessor UserProcessor, authProcessor AuthProcessor) *UserIDResolver {
|
||||
return &UserIDResolver{
|
||||
userProcessor: userProcessor,
|
||||
authProcessor: authProcessor,
|
||||
}
|
||||
}
|
||||
|
||||
func (uir *UserIDResolver) Handle() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (uir *UserIDResolver) resolveUserID(c *gin.Context, userID uuid.UUID) (*contract.UserResponse, error) {
|
||||
user, err := uir.userProcessor.GetUserByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("UserIDResolver::resolveGopayUserID -> userID could not be resolved")
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (uir *UserIDResolver) validate(c *gin.Context, tokenString string) string {
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"eslogad-be/internal/contract"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserProcessor interface {
|
||||
GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"eslogad-be/internal/contract"
|
||||
"eslogad-be/internal/entities"
|
||||
"eslogad-be/internal/transformer"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserProcessorImpl struct {
|
||||
userRepo UserRepository
|
||||
profileRepo UserProfileRepository
|
||||
}
|
||||
|
||||
type UserProfileRepository interface {
|
||||
GetByUserID(ctx context.Context, userID uuid.UUID) (*entities.UserProfile, error)
|
||||
Create(ctx context.Context, profile *entities.UserProfile) error
|
||||
Upsert(ctx context.Context, profile *entities.UserProfile) error
|
||||
Update(ctx context.Context, profile *entities.UserProfile) error
|
||||
}
|
||||
|
||||
func NewUserProcessor(
|
||||
userRepo UserRepository,
|
||||
profileRepo UserProfileRepository,
|
||||
) *UserProcessorImpl {
|
||||
return &UserProcessorImpl{
|
||||
userRepo: userRepo,
|
||||
profileRepo: profileRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *UserProcessorImpl) CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error) {
|
||||
existingUser, err := p.userRepo.GetByEmail(ctx, req.Email)
|
||||
if err == nil && existingUser != nil {
|
||||
return nil, fmt.Errorf("user with email %s already exists", req.Email)
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
userEntity := transformer.CreateUserRequestToEntity(req, string(passwordHash))
|
||||
|
||||
err = p.userRepo.Create(ctx, userEntity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
// create default user profile
|
||||
defaultFullName := userEntity.Name
|
||||
profile := &entities.UserProfile{
|
||||
UserID: userEntity.ID,
|
||||
FullName: defaultFullName,
|
||||
Timezone: "Asia/Jakarta",
|
||||
Locale: "id-ID",
|
||||
Preferences: entities.JSONB{},
|
||||
NotificationPrefs: entities.JSONB{},
|
||||
}
|
||||
_ = p.profileRepo.Create(ctx, profile)
|
||||
|
||||
return transformer.EntityToContract(userEntity), nil
|
||||
}
|
||||
|
||||
func (p *UserProcessorImpl) UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) {
|
||||
existingUser, err := p.userRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
if req.Email != nil && *req.Email != existingUser.Email {
|
||||
existingUserByEmail, err := p.userRepo.GetByEmail(ctx, *req.Email)
|
||||
if err == nil && existingUserByEmail != nil && existingUserByEmail.ID != id {
|
||||
return nil, fmt.Errorf("user with email %s already exists", *req.Email)
|
||||
}
|
||||
}
|
||||
|
||||
updated := transformer.UpdateUserEntity(existingUser, req)
|
||||
|
||||
err = p.userRepo.Update(ctx, updated)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
return transformer.EntityToContract(updated), nil
|
||||
}
|
||||
|
||||
func (p *UserProcessorImpl) DeleteUser(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := p.userRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
err = p.userRepo.Delete(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete user: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *UserProcessorImpl) GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) {
|
||||
user, err := p.userRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
return transformer.EntityToContract(user), nil
|
||||
}
|
||||
|
||||
func (p *UserProcessorImpl) GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error) {
|
||||
user, err := p.userRepo.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
return transformer.EntityToContract(user), nil
|
||||
}
|
||||
|
||||
func (p *UserProcessorImpl) ListUsers(ctx context.Context, page, limit int) ([]contract.UserResponse, int, error) {
|
||||
offset := (page - 1) * limit
|
||||
|
||||
filters := map[string]interface{}{}
|
||||
|
||||
users, totalCount, err := p.userRepo.List(ctx, filters, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get users: %w", err)
|
||||
}
|
||||
|
||||
responses := transformer.EntitiesToContracts(users)
|
||||
return responses, int(totalCount), nil
|
||||
}
|
||||
|
||||
func (p *UserProcessorImpl) GetUserEntityByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
user, err := p.userRepo.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (p *UserProcessorImpl) ChangePassword(ctx context.Context, userID uuid.UUID, req *contract.ChangePasswordRequest) error {
|
||||
user, err := p.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword))
|
||||
if err != nil {
|
||||
return fmt.Errorf("current password is incorrect")
|
||||
}
|
||||
|
||||
newPasswordHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash new password: %w", err)
|
||||
}
|
||||
|
||||
err = p.userRepo.UpdatePassword(ctx, userID, string(newPasswordHash))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *UserProcessorImpl) ActivateUser(ctx context.Context, userID uuid.UUID) error {
|
||||
_, err := p.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
err = p.userRepo.UpdateActiveStatus(ctx, userID, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to activate user: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *UserProcessorImpl) DeactivateUser(ctx context.Context, userID uuid.UUID) error {
|
||||
_, err := p.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
err = p.userRepo.UpdateActiveStatus(ctx, userID, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to deactivate user: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RBAC implementations
|
||||
func (p *UserProcessorImpl) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]contract.RoleResponse, error) {
|
||||
roles, err := p.userRepo.GetRolesByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.RolesToContract(roles), nil
|
||||
}
|
||||
|
||||
func (p *UserProcessorImpl) GetUserPermissionCodes(ctx context.Context, userID uuid.UUID) ([]string, error) {
|
||||
perms, err := p.userRepo.GetPermissionsByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
codes := make([]string, 0, len(perms))
|
||||
for _, p := range perms {
|
||||
codes = append(codes, p.Code)
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
func (p *UserProcessorImpl) GetUserPositions(ctx context.Context, userID uuid.UUID) ([]contract.PositionResponse, error) {
|
||||
positions, err := p.userRepo.GetPositionsByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.PositionsToContract(positions), nil
|
||||
}
|
||||
|
||||
func (p *UserProcessorImpl) GetUserProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) {
|
||||
prof, err := p.profileRepo.GetByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.ProfileEntityToContract(prof), nil
|
||||
}
|
||||
|
||||
func (p *UserProcessorImpl) UpdateUserProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) {
|
||||
existing, _ := p.profileRepo.GetByUserID(ctx, userID)
|
||||
entity := transformer.ProfileUpdateToEntity(userID, req, existing)
|
||||
if existing == nil {
|
||||
if err := p.profileRepo.Create(ctx, entity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := p.profileRepo.Update(ctx, entity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return transformer.ProfileEntityToContract(entity), nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"eslogad-be/internal/entities"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
Create(ctx context.Context, user *entities.User) error
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*entities.User, error)
|
||||
GetByEmail(ctx context.Context, email string) (*entities.User, error)
|
||||
GetByRole(ctx context.Context, role entities.UserRole) ([]*entities.User, error)
|
||||
GetActiveUsers(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error)
|
||||
Update(ctx context.Context, user *entities.User) error
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error
|
||||
UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error
|
||||
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.User, int64, error)
|
||||
Count(ctx context.Context, filters map[string]interface{}) (int64, error)
|
||||
|
||||
GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Role, error)
|
||||
GetPermissionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Permission, error)
|
||||
GetPositionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Position, error)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"eslogad-be/internal/entities"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TitleRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTitleRepository(db *gorm.DB) *TitleRepository {
|
||||
return &TitleRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *TitleRepository) ListAll(ctx context.Context) ([]entities.Title, error) {
|
||||
var titles []entities.Title
|
||||
if err := r.db.WithContext(ctx).Order("name ASC").Find(&titles).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return titles, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"eslogad-be/internal/entities"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserProfileRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserProfileRepository(db *gorm.DB) *UserProfileRepository {
|
||||
return &UserProfileRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserProfileRepository) GetByUserID(ctx context.Context, userID uuid.UUID) (*entities.UserProfile, error) {
|
||||
var p entities.UserProfile
|
||||
if err := r.db.WithContext(ctx).First(&p, "user_id = ?", userID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (r *UserProfileRepository) Create(ctx context.Context, profile *entities.UserProfile) error {
|
||||
return r.db.WithContext(ctx).Create(profile).Error
|
||||
}
|
||||
|
||||
func (r *UserProfileRepository) Upsert(ctx context.Context, profile *entities.UserProfile) error {
|
||||
return r.db.WithContext(ctx).Clauses(
|
||||
clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"full_name", "display_name", "phone", "avatar_url", "job_title", "employee_no", "bio", "timezone", "locale", "preferences", "notification_prefs"}),
|
||||
},
|
||||
).Create(profile).Error
|
||||
}
|
||||
|
||||
func (r *UserProfileRepository) Update(ctx context.Context, profile *entities.UserProfile) error {
|
||||
return r.db.WithContext(ctx).Model(&entities.UserProfile{}).Where("user_id = ?", profile.UserID).Updates(profile).Error
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"eslogad-be/internal/entities"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserRepositoryImpl struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) *UserRepositoryImpl {
|
||||
return &UserRepositoryImpl{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserRepositoryImpl) Create(ctx context.Context, user *entities.User) error {
|
||||
return r.db.WithContext(ctx).Create(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.User, error) {
|
||||
var user entities.User
|
||||
err := r.db.WithContext(ctx).First(&user, "id = ?", id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
var user entities.User
|
||||
err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepositoryImpl) GetByRole(ctx context.Context, role entities.UserRole) ([]*entities.User, error) {
|
||||
var users []*entities.User
|
||||
err := r.db.WithContext(ctx).Where("role = ?", role).Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
func (r *UserRepositoryImpl) GetActiveUsers(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error) {
|
||||
var users []*entities.User
|
||||
err := r.db.WithContext(ctx).
|
||||
Where(" is_active = ?", organizationID, true).
|
||||
Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
func (r *UserRepositoryImpl) Update(ctx context.Context, user *entities.User) error {
|
||||
return r.db.WithContext(ctx).Save(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return r.db.WithContext(ctx).Delete(&entities.User{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
func (r *UserRepositoryImpl) UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error {
|
||||
return r.db.WithContext(ctx).Model(&entities.User{}).
|
||||
Where("id = ?", id).
|
||||
Update("password_hash", passwordHash).Error
|
||||
}
|
||||
|
||||
func (r *UserRepositoryImpl) UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error {
|
||||
return r.db.WithContext(ctx).Model(&entities.User{}).
|
||||
Where("id = ?", id).
|
||||
Update("is_active", isActive).Error
|
||||
}
|
||||
|
||||
func (r *UserRepositoryImpl) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.User, int64, error) {
|
||||
var users []*entities.User
|
||||
var total int64
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&entities.User{})
|
||||
|
||||
for key, value := range filters {
|
||||
query = query.Where(key+" = ?", value)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err := query.Limit(limit).Offset(offset).Find(&users).Error
|
||||
return users, total, err
|
||||
}
|
||||
|
||||
func (r *UserRepositoryImpl) Count(ctx context.Context, filters map[string]interface{}) (int64, error) {
|
||||
var count int64
|
||||
query := r.db.WithContext(ctx).Model(&entities.User{})
|
||||
|
||||
for key, value := range filters {
|
||||
query = query.Where(key+" = ?", value)
|
||||
}
|
||||
|
||||
err := query.Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// RBAC helpers
|
||||
func (r *UserRepositoryImpl) GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) {
|
||||
var roles []entities.Role
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("roles as r").
|
||||
Select("r.*").
|
||||
Joins("JOIN user_role ur ON ur.role_id = r.id AND ur.removed_at IS NULL").
|
||||
Where("ur.user_id = ?", userID).
|
||||
Find(&roles).Error
|
||||
return roles, err
|
||||
}
|
||||
|
||||
func (r *UserRepositoryImpl) GetPermissionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Permission, error) {
|
||||
var perms []entities.Permission
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("permissions as p").
|
||||
Select("DISTINCT p.*").
|
||||
Joins("JOIN role_permissions rp ON rp.permission_id = p.id").
|
||||
Joins("JOIN user_role ur ON ur.role_id = rp.role_id AND ur.removed_at IS NULL").
|
||||
Where("ur.user_id = ?", userID).
|
||||
Find(&perms).Error
|
||||
return perms, err
|
||||
}
|
||||
|
||||
func (r *UserRepositoryImpl) GetPositionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Position, error) {
|
||||
var positions []entities.Position
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("positions as p").
|
||||
Select("p.*").
|
||||
Joins("JOIN user_position up ON up.position_id = p.id AND up.removed_at IS NULL").
|
||||
Where("up.user_id = ?", userID).
|
||||
Find(&positions).Error
|
||||
return positions, err
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
type AuthHandler interface {
|
||||
Login(c *gin.Context)
|
||||
RefreshToken(c *gin.Context)
|
||||
GetProfile(c *gin.Context)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
type HealthHandler interface {
|
||||
HealthCheck(c *gin.Context)
|
||||
}
|
||||
|
||||
type UserHandler interface {
|
||||
ListUsers(c *gin.Context)
|
||||
GetProfile(c *gin.Context)
|
||||
UpdateProfile(c *gin.Context)
|
||||
ChangePassword(c *gin.Context)
|
||||
ListTitles(c *gin.Context)
|
||||
}
|
||||
|
||||
type FileHandler interface {
|
||||
UploadProfileAvatar(c *gin.Context)
|
||||
UploadDocument(c *gin.Context)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
type AuthMiddleware interface {
|
||||
RequireAuth() gin.HandlerFunc
|
||||
RequireRole(allowedRoles ...string) gin.HandlerFunc
|
||||
RequireAdminOrManager() gin.HandlerFunc
|
||||
RequireAdmin() gin.HandlerFunc
|
||||
RequireSuperAdmin() gin.HandlerFunc
|
||||
RequireActiveUser() gin.HandlerFunc
|
||||
RequirePermissions(required ...string) gin.HandlerFunc
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"eslogad-be/config"
|
||||
"eslogad-be/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
config *config.Config
|
||||
authHandler AuthHandler
|
||||
healthHandler HealthHandler
|
||||
authMiddleware AuthMiddleware
|
||||
userHandler UserHandler
|
||||
fileHandler FileHandler
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
cfg *config.Config,
|
||||
authHandler AuthHandler,
|
||||
authMiddleware AuthMiddleware,
|
||||
healthHandler HealthHandler,
|
||||
userHandler UserHandler,
|
||||
fileHandler FileHandler,
|
||||
) *Router {
|
||||
return &Router{
|
||||
config: cfg,
|
||||
authHandler: authHandler,
|
||||
authMiddleware: authMiddleware,
|
||||
healthHandler: healthHandler,
|
||||
userHandler: userHandler,
|
||||
fileHandler: fileHandler,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) Init() *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine := gin.New()
|
||||
engine.Use(
|
||||
middleware.JsonAPI(),
|
||||
middleware.CorrelationID(),
|
||||
middleware.Recover(),
|
||||
middleware.HTTPStatLogger(),
|
||||
middleware.PopulateContext(),
|
||||
)
|
||||
|
||||
r.addAppRoutes(engine)
|
||||
return engine
|
||||
}
|
||||
|
||||
func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
rg.GET("/health", r.healthHandler.HealthCheck)
|
||||
|
||||
v1 := rg.Group("/api/v1")
|
||||
{
|
||||
auth := v1.Group("/auth")
|
||||
{
|
||||
auth.POST("/login", r.authHandler.Login)
|
||||
auth.POST("/refresh", r.authHandler.RefreshToken)
|
||||
auth.GET("/profile", r.authHandler.GetProfile)
|
||||
}
|
||||
|
||||
users := v1.Group("/users")
|
||||
users.Use(r.authMiddleware.RequireAuth())
|
||||
{
|
||||
users.GET("", r.authMiddleware.RequirePermissions("user.view"), r.userHandler.ListUsers)
|
||||
users.GET("/profile", r.userHandler.GetProfile)
|
||||
users.PUT("/profile", r.userHandler.UpdateProfile)
|
||||
users.PUT(":id/password", r.userHandler.ChangePassword)
|
||||
users.GET("/titles", r.userHandler.ListTitles)
|
||||
users.POST("/profile/avatar", r.fileHandler.UploadProfileAvatar)
|
||||
}
|
||||
|
||||
files := v1.Group("/files")
|
||||
files.Use(r.authMiddleware.RequireAuth())
|
||||
{
|
||||
files.POST("/documents", r.fileHandler.UploadDocument)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"eslogad-be/internal/contract"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type AuthServiceImpl struct {
|
||||
userProcessor UserProcessor
|
||||
jwtSecret string
|
||||
tokenTTL time.Duration
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Roles []string `json:"roles"`
|
||||
Permissions []string `json:"permissions"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewAuthService(userProcessor UserProcessor, jwtSecret string) *AuthServiceImpl {
|
||||
return &AuthServiceImpl{
|
||||
userProcessor: userProcessor,
|
||||
jwtSecret: jwtSecret,
|
||||
tokenTTL: 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthServiceImpl) Login(ctx context.Context, req *contract.LoginRequest) (*contract.LoginResponse, error) {
|
||||
userResponse, err := s.userProcessor.GetUserByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
if !userResponse.IsActive {
|
||||
return nil, fmt.Errorf("user account is deactivated")
|
||||
}
|
||||
|
||||
userEntity, err := s.userProcessor.GetUserEntityByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(userEntity.PasswordHash), []byte(req.Password))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
// fetch roles, permissions, positions for response and token
|
||||
roles, _ := s.userProcessor.GetUserRoles(ctx, userResponse.ID)
|
||||
permCodes, _ := s.userProcessor.GetUserPermissionCodes(ctx, userResponse.ID)
|
||||
positions, _ := s.userProcessor.GetUserPositions(ctx, userResponse.ID)
|
||||
|
||||
token, expiresAt, err := s.generateToken(userResponse, roles, permCodes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate token: %w", err)
|
||||
}
|
||||
|
||||
return &contract.LoginResponse{
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt,
|
||||
User: *userResponse,
|
||||
Roles: roles,
|
||||
Permissions: permCodes,
|
||||
Positions: positions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthServiceImpl) ValidateToken(tokenString string) (*contract.UserResponse, error) {
|
||||
claims, err := s.parseToken(tokenString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
|
||||
userResponse, err := s.userProcessor.GetUserByID(context.Background(), claims.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
if !userResponse.IsActive {
|
||||
return nil, fmt.Errorf("user account is deactivated")
|
||||
}
|
||||
|
||||
return userResponse, nil
|
||||
}
|
||||
|
||||
func (s *AuthServiceImpl) RefreshToken(ctx context.Context, tokenString string) (*contract.LoginResponse, error) {
|
||||
claims, err := s.parseToken(tokenString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
|
||||
userResponse, err := s.userProcessor.GetUserByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
if !userResponse.IsActive {
|
||||
return nil, fmt.Errorf("user account is deactivated")
|
||||
}
|
||||
|
||||
roles, _ := s.userProcessor.GetUserRoles(ctx, userResponse.ID)
|
||||
permCodes, _ := s.userProcessor.GetUserPermissionCodes(ctx, userResponse.ID)
|
||||
newToken, expiresAt, err := s.generateToken(userResponse, roles, permCodes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate token: %w", err)
|
||||
}
|
||||
|
||||
positions, _ := s.userProcessor.GetUserPositions(ctx, userResponse.ID)
|
||||
return &contract.LoginResponse{
|
||||
Token: newToken,
|
||||
ExpiresAt: expiresAt,
|
||||
User: *userResponse,
|
||||
Roles: roles,
|
||||
Permissions: permCodes,
|
||||
Positions: positions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthServiceImpl) Logout(ctx context.Context, tokenString string) error {
|
||||
_, err := s.parseToken(tokenString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthServiceImpl) generateToken(user *contract.UserResponse, roles []contract.RoleResponse, permissionCodes []string) (string, time.Time, error) {
|
||||
expiresAt := time.Now().Add(s.tokenTTL)
|
||||
|
||||
roleCodes := make([]string, 0, len(roles))
|
||||
for _, r := range roles {
|
||||
roleCodes = append(roleCodes, r.Code)
|
||||
}
|
||||
|
||||
claims := &Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Roles: roleCodes,
|
||||
Permissions: permissionCodes,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "eslogad-be",
|
||||
Subject: user.ID.String(),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(s.jwtSecret))
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
|
||||
return tokenString, expiresAt, nil
|
||||
}
|
||||
|
||||
func (s *AuthServiceImpl) parseToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(s.jwtSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
func (s *AuthServiceImpl) ExtractAccess(tokenString string) (roles []string, permissions []string, err error) {
|
||||
claims, err := s.parseToken(tokenString)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return claims.Roles, claims.Permissions, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"eslogad-be/internal/contract"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type FileStorage interface {
|
||||
Upload(ctx context.Context, bucket, key string, content []byte, contentType string) (string, error)
|
||||
EnsureBucket(ctx context.Context, bucket string) error
|
||||
}
|
||||
|
||||
type FileServiceImpl struct {
|
||||
storage FileStorage
|
||||
userProcessor UserProcessor
|
||||
profileBucket string
|
||||
docBucket string
|
||||
}
|
||||
|
||||
func NewFileService(storage FileStorage, userProcessor UserProcessor, profileBucket, docBucket string) *FileServiceImpl {
|
||||
return &FileServiceImpl{storage: storage, userProcessor: userProcessor, profileBucket: profileBucket, docBucket: docBucket}
|
||||
}
|
||||
|
||||
func (s *FileServiceImpl) UploadProfileAvatar(ctx context.Context, userID uuid.UUID, filename string, content []byte, contentType string) (string, error) {
|
||||
if err := s.storage.EnsureBucket(ctx, s.profileBucket); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
|
||||
if ext := mimeExtFromContentType(contentType); ext != "" {
|
||||
ext = ext
|
||||
}
|
||||
key := buildObjectKey("profile", userID, ext)
|
||||
url, err := s.storage.Upload(ctx, s.profileBucket, key, content, contentType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, _ = s.userProcessor.UpdateUserProfile(ctx, userID, &contract.UpdateUserProfileRequest{AvatarURL: &url})
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func (s *FileServiceImpl) UploadDocument(ctx context.Context, userID uuid.UUID, filename string, content []byte, contentType string) (string, string, error) {
|
||||
if err := s.storage.EnsureBucket(ctx, s.docBucket); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
|
||||
if ext := mimeExtFromContentType(contentType); ext != "" {
|
||||
ext = ext
|
||||
}
|
||||
key := buildObjectKey("documents", userID, ext)
|
||||
url, err := s.storage.Upload(ctx, s.docBucket, key, content, contentType)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return url, key, nil
|
||||
}
|
||||
|
||||
func buildObjectKey(prefix string, userID uuid.UUID, ext string) string {
|
||||
now := time.Now().UTC()
|
||||
parts := []string{
|
||||
prefix,
|
||||
userID.String(),
|
||||
now.Format("2006/01/02"),
|
||||
uuid.New().String(),
|
||||
}
|
||||
key := strings.Join(parts, "/")
|
||||
if ext != "" {
|
||||
key += "." + ext
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func mimeExtFromContentType(ct string) string {
|
||||
switch strings.ToLower(ct) {
|
||||
case "image/jpeg", "image/jpg":
|
||||
return "jpg"
|
||||
case "image/png":
|
||||
return "png"
|
||||
case "image/webp":
|
||||
return "webp"
|
||||
case "application/pdf":
|
||||
return "pdf"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"eslogad-be/internal/contract"
|
||||
"eslogad-be/internal/entities"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserProcessor interface {
|
||||
UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error)
|
||||
CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error)
|
||||
DeleteUser(ctx context.Context, id uuid.UUID) error
|
||||
GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error)
|
||||
ListUsers(ctx context.Context, page, limit int) ([]contract.UserResponse, int, error)
|
||||
GetUserEntityByEmail(ctx context.Context, email string) (*entities.User, error)
|
||||
ChangePassword(ctx context.Context, userID uuid.UUID, req *contract.ChangePasswordRequest) error
|
||||
|
||||
GetUserRoles(ctx context.Context, userID uuid.UUID) ([]contract.RoleResponse, error)
|
||||
GetUserPermissionCodes(ctx context.Context, userID uuid.UUID) ([]string, error)
|
||||
GetUserPositions(ctx context.Context, userID uuid.UUID) ([]contract.PositionResponse, error)
|
||||
|
||||
GetUserProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error)
|
||||
UpdateUserProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"eslogad-be/internal/contract"
|
||||
"eslogad-be/internal/entities"
|
||||
"eslogad-be/internal/transformer"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserServiceImpl struct {
|
||||
userProcessor UserProcessor
|
||||
titleRepo TitleRepository
|
||||
}
|
||||
|
||||
type TitleRepository interface {
|
||||
ListAll(ctx context.Context) ([]entities.Title, error)
|
||||
}
|
||||
|
||||
func NewUserService(userProcessor UserProcessor, titleRepo TitleRepository) *UserServiceImpl {
|
||||
return &UserServiceImpl{
|
||||
userProcessor: userProcessor,
|
||||
titleRepo: titleRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UserServiceImpl) CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error) {
|
||||
return s.userProcessor.CreateUser(ctx, req)
|
||||
}
|
||||
|
||||
func (s *UserServiceImpl) UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) {
|
||||
return s.userProcessor.UpdateUser(ctx, id, req)
|
||||
}
|
||||
|
||||
func (s *UserServiceImpl) DeleteUser(ctx context.Context, id uuid.UUID) error {
|
||||
return s.userProcessor.DeleteUser(ctx, id)
|
||||
}
|
||||
|
||||
func (s *UserServiceImpl) GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) {
|
||||
return s.userProcessor.GetUserByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *UserServiceImpl) GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error) {
|
||||
return s.userProcessor.GetUserByEmail(ctx, email)
|
||||
}
|
||||
|
||||
func (s *UserServiceImpl) ListUsers(ctx context.Context, req *contract.ListUsersRequest) (*contract.ListUsersResponse, error) {
|
||||
page := req.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
userResponses, totalCount, err := s.userProcessor.ListUsers(ctx, page, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &contract.ListUsersResponse{
|
||||
Users: userResponses,
|
||||
Pagination: transformer.CreatePaginationResponse(totalCount, page, limit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *UserServiceImpl) ChangePassword(ctx context.Context, userID uuid.UUID, req *contract.ChangePasswordRequest) error {
|
||||
return s.userProcessor.ChangePassword(ctx, userID, req)
|
||||
}
|
||||
|
||||
func (s *UserServiceImpl) GetProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) {
|
||||
return s.userProcessor.GetUserProfile(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *UserServiceImpl) UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) {
|
||||
return s.userProcessor.UpdateUserProfile(ctx, userID, req)
|
||||
}
|
||||
|
||||
func (s *UserServiceImpl) ListTitles(ctx context.Context) (*contract.ListTitlesResponse, error) {
|
||||
if s.titleRepo == nil {
|
||||
return &contract.ListTitlesResponse{Titles: []contract.TitleResponse{}}, nil
|
||||
}
|
||||
titles, err := s.titleRepo.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &contract.ListTitlesResponse{Titles: transformer.TitlesToContract(titles)}, nil
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package transformer
|
||||
|
||||
import (
|
||||
"eslogad-be/internal/contract"
|
||||
"eslogad-be/internal/entities"
|
||||
"math"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func PaginationToRequest(page, limit int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
return page, limit
|
||||
}
|
||||
|
||||
func CreatePaginationResponse(totalCount, page, limit int) contract.PaginationResponse {
|
||||
totalPages := int(math.Ceil(float64(totalCount) / float64(limit)))
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
return contract.PaginationResponse{
|
||||
TotalCount: totalCount,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
}
|
||||
|
||||
func CreateListUsersResponse(users []contract.UserResponse, totalCount, page, limit int) *contract.ListUsersResponse {
|
||||
pagination := CreatePaginationResponse(totalCount, page, limit)
|
||||
return &contract.ListUsersResponse{
|
||||
Users: users,
|
||||
Pagination: pagination,
|
||||
}
|
||||
}
|
||||
|
||||
func CreateErrorResponse(message string, code int) *contract.ErrorResponse {
|
||||
return &contract.ErrorResponse{
|
||||
Error: "error",
|
||||
Message: message,
|
||||
Code: code,
|
||||
}
|
||||
}
|
||||
|
||||
func CreateValidationErrorResponse(message string, details map[string]string) *contract.ValidationErrorResponse {
|
||||
return &contract.ValidationErrorResponse{
|
||||
Error: "validation_error",
|
||||
Message: message,
|
||||
Details: details,
|
||||
Code: 400,
|
||||
}
|
||||
}
|
||||
|
||||
func CreateSuccessResponse(message string, data interface{}) *contract.SuccessResponse {
|
||||
return &contract.SuccessResponse{
|
||||
Message: message,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func RolesToContract(roles []entities.Role) []contract.RoleResponse {
|
||||
if roles == nil {
|
||||
return nil
|
||||
}
|
||||
res := make([]contract.RoleResponse, 0, len(roles))
|
||||
for _, r := range roles {
|
||||
res = append(res, contract.RoleResponse{ID: r.ID, Name: r.Name, Code: r.Code})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func PositionsToContract(positions []entities.Position) []contract.PositionResponse {
|
||||
if positions == nil {
|
||||
return nil
|
||||
}
|
||||
res := make([]contract.PositionResponse, 0, len(positions))
|
||||
for _, p := range positions {
|
||||
res = append(res, contract.PositionResponse{ID: p.ID, Name: p.Name, Code: p.Code, Path: p.Path})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func ProfileEntityToContract(p *entities.UserProfile) *contract.UserProfileResponse {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return &contract.UserProfileResponse{
|
||||
UserID: p.UserID,
|
||||
FullName: p.FullName,
|
||||
DisplayName: p.DisplayName,
|
||||
Phone: p.Phone,
|
||||
AvatarURL: p.AvatarURL,
|
||||
JobTitle: p.JobTitle,
|
||||
EmployeeNo: p.EmployeeNo,
|
||||
Bio: p.Bio,
|
||||
Timezone: p.Timezone,
|
||||
Locale: p.Locale,
|
||||
Preferences: map[string]interface{}(p.Preferences),
|
||||
NotificationPrefs: map[string]interface{}(p.NotificationPrefs),
|
||||
LastSeenAt: p.LastSeenAt,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ProfileUpdateToEntity(userID uuid.UUID, req *contract.UpdateUserProfileRequest, existing *entities.UserProfile) *entities.UserProfile {
|
||||
prof := &entities.UserProfile{}
|
||||
if existing != nil {
|
||||
*prof = *existing
|
||||
} else {
|
||||
prof.UserID = userID
|
||||
}
|
||||
if req.FullName != nil {
|
||||
prof.FullName = *req.FullName
|
||||
}
|
||||
if req.DisplayName != nil {
|
||||
prof.DisplayName = req.DisplayName
|
||||
}
|
||||
if req.Phone != nil {
|
||||
prof.Phone = req.Phone
|
||||
}
|
||||
if req.AvatarURL != nil {
|
||||
prof.AvatarURL = req.AvatarURL
|
||||
}
|
||||
if req.JobTitle != nil {
|
||||
prof.JobTitle = req.JobTitle
|
||||
}
|
||||
if req.EmployeeNo != nil {
|
||||
prof.EmployeeNo = req.EmployeeNo
|
||||
}
|
||||
if req.Bio != nil {
|
||||
prof.Bio = req.Bio
|
||||
}
|
||||
if req.Timezone != nil {
|
||||
prof.Timezone = *req.Timezone
|
||||
}
|
||||
if req.Locale != nil {
|
||||
prof.Locale = *req.Locale
|
||||
}
|
||||
if req.Preferences != nil {
|
||||
prof.Preferences = entities.JSONB(*req.Preferences)
|
||||
}
|
||||
if req.NotificationPrefs != nil {
|
||||
prof.NotificationPrefs = entities.JSONB(*req.NotificationPrefs)
|
||||
}
|
||||
return prof
|
||||
}
|
||||
|
||||
func TitlesToContract(titles []entities.Title) []contract.TitleResponse {
|
||||
if titles == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]contract.TitleResponse, 0, len(titles))
|
||||
for _, t := range titles {
|
||||
out = append(out, contract.TitleResponse{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Code: t.Code,
|
||||
Description: t.Description,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package transformer
|
||||
|
||||
import (
|
||||
"eslogad-be/internal/contract"
|
||||
"eslogad-be/internal/entities"
|
||||
)
|
||||
|
||||
func CreateUserRequestToEntity(req *contract.CreateUserRequest, passwordHash string) *entities.User {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
return &entities.User{
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
PasswordHash: passwordHash,
|
||||
IsActive: true,
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateUserEntity(existing *entities.User, req *contract.UpdateUserRequest) *entities.User {
|
||||
if existing == nil || req == nil {
|
||||
return existing
|
||||
}
|
||||
if req.Name != nil {
|
||||
existing.Name = *req.Name
|
||||
}
|
||||
if req.Email != nil {
|
||||
existing.Email = *req.Email
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
existing.IsActive = *req.IsActive
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
func EntityToContract(user *entities.User) *contract.UserResponse {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
return &contract.UserResponse{
|
||||
ID: user.ID,
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
IsActive: user.IsActive,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func EntitiesToContracts(users []*entities.User) []contract.UserResponse {
|
||||
if users == nil {
|
||||
return nil
|
||||
}
|
||||
responses := make([]contract.UserResponse, len(users))
|
||||
for i, u := range users {
|
||||
resp := EntityToContract(u)
|
||||
if resp != nil {
|
||||
responses[i] = *resp
|
||||
}
|
||||
}
|
||||
return responses
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const DateFormatDDMMYYYY = "02-01-2006"
|
||||
|
||||
// ParseDateToJakartaTime parses a date string in DD-MM-YYYY format and converts it to Jakarta timezone
|
||||
// Returns start of day (00:00:00) in Jakarta timezone
|
||||
func ParseDateToJakartaTime(dateStr string) (*time.Time, error) {
|
||||
if dateStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
date, err := time.Parse(DateFormatDDMMYYYY, dateStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jakartaLoc, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jakartaTime := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, jakartaLoc)
|
||||
return &jakartaTime, nil
|
||||
}
|
||||
|
||||
// ParseDateToJakartaTimeEndOfDay parses a date string in DD-MM-YYYY format and converts it to Jakarta timezone
|
||||
// Returns end of day (23:59:59.999999999) in Jakarta timezone
|
||||
func ParseDateToJakartaTimeEndOfDay(dateStr string) (*time.Time, error) {
|
||||
if dateStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
date, err := time.Parse(DateFormatDDMMYYYY, dateStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jakartaLoc, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jakartaTime := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, jakartaLoc)
|
||||
return &jakartaTime, nil
|
||||
}
|
||||
|
||||
// ParseDateRangeToJakartaTime parses date_from and date_to strings and returns them in Jakarta timezone
|
||||
// date_from will be start of day (00:00:00), date_to will be end of day (23:59:59.999999999)
|
||||
func ParseDateRangeToJakartaTime(dateFrom, dateTo string) (*time.Time, *time.Time, error) {
|
||||
var fromTime, toTime *time.Time
|
||||
var err error
|
||||
|
||||
if dateFrom != "" {
|
||||
fromTime, err = ParseDateToJakartaTime(dateFrom)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if dateTo != "" {
|
||||
toTime, err = ParseDateToJakartaTimeEndOfDay(dateTo)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return fromTime, toTime, nil
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseDateToJakartaTime(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dateStr string
|
||||
expected *time.Time
|
||||
hasError bool
|
||||
}{
|
||||
{
|
||||
name: "valid date",
|
||||
dateStr: "06-08-2025",
|
||||
expected: nil, // Will be set during test
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
dateStr: "",
|
||||
expected: nil,
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid date format",
|
||||
dateStr: "2025-08-06",
|
||||
hasError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ParseDateToJakartaTime(tt.dateStr)
|
||||
|
||||
if tt.hasError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.expected == nil && tt.dateStr == "" {
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil but got %v", result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if result == nil && tt.dateStr != "" {
|
||||
t.Errorf("Expected time but got nil")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it's in Jakarta timezone
|
||||
jakartaLoc, _ := time.LoadLocation("Asia/Jakarta")
|
||||
if result.Location().String() != jakartaLoc.String() {
|
||||
t.Errorf("Expected Jakarta timezone but got %v", result.Location())
|
||||
}
|
||||
|
||||
// Check if it's start of day
|
||||
if result.Hour() != 0 || result.Minute() != 0 || result.Second() != 0 {
|
||||
t.Errorf("Expected start of day but got %v", result.Format("15:04:05"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDateToJakartaTimeEndOfDay(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dateStr string
|
||||
expected *time.Time
|
||||
hasError bool
|
||||
}{
|
||||
{
|
||||
name: "valid date",
|
||||
dateStr: "06-08-2025",
|
||||
expected: nil, // Will be set during test
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
dateStr: "",
|
||||
expected: nil,
|
||||
hasError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ParseDateToJakartaTimeEndOfDay(tt.dateStr)
|
||||
|
||||
if tt.hasError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.expected == nil && tt.dateStr == "" {
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil but got %v", result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if result == nil && tt.dateStr != "" {
|
||||
t.Errorf("Expected time but got nil")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it's in Jakarta timezone
|
||||
jakartaLoc, _ := time.LoadLocation("Asia/Jakarta")
|
||||
if result.Location().String() != jakartaLoc.String() {
|
||||
t.Errorf("Expected Jakarta timezone but got %v", result.Location())
|
||||
}
|
||||
|
||||
// Check if it's end of day
|
||||
if result.Hour() != 23 || result.Minute() != 59 || result.Second() != 59 {
|
||||
t.Errorf("Expected end of day but got %v", result.Format("15:04:05"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDateRangeToJakartaTime(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dateFrom string
|
||||
dateTo string
|
||||
hasError bool
|
||||
}{
|
||||
{
|
||||
name: "valid date range",
|
||||
dateFrom: "06-08-2025",
|
||||
dateTo: "06-08-2025",
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "empty strings",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "only date_from",
|
||||
dateFrom: "06-08-2025",
|
||||
dateTo: "",
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "only date_to",
|
||||
dateFrom: "",
|
||||
dateTo: "06-08-2025",
|
||||
hasError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fromTime, toTime, err := ParseDateRangeToJakartaTime(tt.dateFrom, tt.dateTo)
|
||||
|
||||
if tt.hasError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If dateFrom is provided, check it's start of day
|
||||
if tt.dateFrom != "" && fromTime != nil {
|
||||
jakartaLoc, _ := time.LoadLocation("Asia/Jakarta")
|
||||
if fromTime.Location().String() != jakartaLoc.String() {
|
||||
t.Errorf("Expected Jakarta timezone for date_from but got %v", fromTime.Location())
|
||||
}
|
||||
if fromTime.Hour() != 0 || fromTime.Minute() != 0 || fromTime.Second() != 0 {
|
||||
t.Errorf("Expected start of day for date_from but got %v", fromTime.Format("15:04:05"))
|
||||
}
|
||||
}
|
||||
|
||||
// If dateTo is provided, check it's end of day
|
||||
if tt.dateTo != "" && toTime != nil {
|
||||
jakartaLoc, _ := time.LoadLocation("Asia/Jakarta")
|
||||
if toTime.Location().String() != jakartaLoc.String() {
|
||||
t.Errorf("Expected Jakarta timezone for date_to but got %v", toTime.Location())
|
||||
}
|
||||
if toTime.Hour() != 23 || toTime.Minute() != 59 || toTime.Second() != 59 {
|
||||
t.Errorf("Expected end of day for date_to but got %v", toTime.Format("15:04:05"))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"eslogad-be/internal/constants"
|
||||
"eslogad-be/internal/contract"
|
||||
"eslogad-be/internal/logger"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func HandleResponse(w http.ResponseWriter, r *http.Request, response *contract.Response, methodName string) {
|
||||
var statusCode int
|
||||
if response.GetSuccess() {
|
||||
statusCode = http.StatusOK
|
||||
} else {
|
||||
responseError := response.GetErrors()[0]
|
||||
statusCode = MapErrorCodeToHttpStatus(responseError.GetCode())
|
||||
}
|
||||
WriteResponse(w, r, *response, statusCode, methodName)
|
||||
}
|
||||
|
||||
func WriteResponse(w http.ResponseWriter, r *http.Request, resp contract.Response, statusCode int, methodName string) {
|
||||
w.WriteHeader(statusCode)
|
||||
response, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
logger.FromContext(r.Context()).Error(methodName, "unable to marshal json response", err)
|
||||
}
|
||||
_, err = w.Write(response)
|
||||
if err != nil {
|
||||
logger.FromContext(r.Context()).Error(methodName, "unable to write to response", err)
|
||||
}
|
||||
}
|
||||
|
||||
func MapErrorCodeToHttpStatus(code string) int {
|
||||
statusCode := constants.HttpErrorMap[code]
|
||||
if statusCode == 0 {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
return statusCode
|
||||
}
|
||||
|
||||
func ExtractEndpointFromURL(requestURL string) string {
|
||||
parsedURL, err := url.Parse(requestURL)
|
||||
if err != nil {
|
||||
return "/"
|
||||
}
|
||||
return parsedURL.Path
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"eslogad-be/internal/constants"
|
||||
"eslogad-be/internal/contract"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserValidatorImpl struct{}
|
||||
|
||||
func NewUserValidator() *UserValidatorImpl {
|
||||
return &UserValidatorImpl{}
|
||||
}
|
||||
|
||||
func (v *UserValidatorImpl) ValidateCreateUserRequest(req *contract.CreateUserRequest) (error, string) {
|
||||
if req == nil {
|
||||
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Email) == "" {
|
||||
return errors.New("email is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if !isValidEmail(req.Email) {
|
||||
return errors.New("email format is invalid"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Password) == "" {
|
||||
return errors.New("password is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if len(req.Password) < 6 {
|
||||
return errors.New("password must be at least 6 characters"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Role) == "" {
|
||||
return errors.New("role is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if !isValidUserRole(req.Role) {
|
||||
return errors.New("invalid user role"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (v *UserValidatorImpl) ValidateUpdateUserRequest(req *contract.UpdateUserRequest) (error, string) {
|
||||
if req == nil {
|
||||
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if req.Email == nil && req.Role == nil && req.IsActive == nil {
|
||||
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if req.Email != nil {
|
||||
if strings.TrimSpace(*req.Email) == "" {
|
||||
return errors.New("email cannot be empty"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
if !isValidEmail(*req.Email) {
|
||||
return errors.New("email format is invalid"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
}
|
||||
|
||||
if req.Role != nil {
|
||||
if strings.TrimSpace(*req.Role) == "" {
|
||||
return errors.New("role cannot be empty"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
if !isValidUserRole(*req.Role) {
|
||||
return errors.New("invalid user role"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (v *UserValidatorImpl) ValidateListUsersRequest(req *contract.ListUsersRequest) (error, string) {
|
||||
if req == nil {
|
||||
return errors.New("request is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if req.Page <= 0 {
|
||||
return errors.New("page must be greater than 0"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if req.Limit <= 0 {
|
||||
return errors.New("limit must be greater than 0"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if req.Limit > 100 {
|
||||
return errors.New("limit cannot exceed 100"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if req.Role != nil && !isValidUserRole(*req.Role) {
|
||||
return errors.New("invalid user role filter"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (v *UserValidatorImpl) ValidateChangePasswordRequest(req *contract.ChangePasswordRequest) (error, string) {
|
||||
if req == nil {
|
||||
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.CurrentPassword) == "" {
|
||||
return errors.New("current_password is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.NewPassword) == "" {
|
||||
return errors.New("new_password is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
if len(req.NewPassword) < 8 {
|
||||
return errors.New("new_password must be at least 8 characters"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if req.CurrentPassword == req.NewPassword {
|
||||
return errors.New("new password must be different from current password"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (v *UserValidatorImpl) ValidateUserID(userID uuid.UUID) (error, string) {
|
||||
if userID == uuid.Nil {
|
||||
return errors.New("user_id is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func isValidUserRole(role string) bool {
|
||||
validRoles := map[string]bool{
|
||||
string(constants.RoleAdmin): true,
|
||||
string(constants.RoleManager): true,
|
||||
string(constants.RoleCashier): true,
|
||||
string(constants.RoleWaiter): true,
|
||||
}
|
||||
return validRoles[role]
|
||||
}
|
||||
|
||||
func (v *UserValidatorImpl) ValidateUpdateUserOutletRequest(req *contract.UpdateUserOutletRequest) (error, string) {
|
||||
if req.OutletID == uuid.Nil {
|
||||
return errors.New("outlet_id is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
func isValidEmail(email string) bool {
|
||||
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
return emailRegex.MatchString(email)
|
||||
}
|
||||
|
||||
func isValidPhone(phone string) bool {
|
||||
phoneRegex := regexp.MustCompile(`^\+?[1-9]\d{1,14}$`)
|
||||
return phoneRegex.MatchString(phone)
|
||||
}
|
||||
|
||||
func isValidRole(role string) bool {
|
||||
validRoles := map[string]bool{
|
||||
"admin": true,
|
||||
"manager": true,
|
||||
"cashier": true,
|
||||
}
|
||||
return validRoles[role]
|
||||
}
|
||||
|
||||
func isValidPlanType(planType string) bool {
|
||||
validPlanTypes := map[string]bool{
|
||||
"basic": true,
|
||||
"premium": true,
|
||||
"enterprise": true,
|
||||
}
|
||||
return validPlanTypes[planType]
|
||||
}
|
||||
|
||||
func formatValidationError(err error) error {
|
||||
if validationErrors, ok := err.(validator.ValidationErrors); ok {
|
||||
var errorMessages []string
|
||||
for _, fieldError := range validationErrors {
|
||||
switch fieldError.Tag() {
|
||||
case "required":
|
||||
errorMessages = append(errorMessages, fieldError.Field()+" is required")
|
||||
case "email":
|
||||
errorMessages = append(errorMessages, fieldError.Field()+" must be a valid email")
|
||||
case "min":
|
||||
errorMessages = append(errorMessages, fieldError.Field()+" must be at least "+fieldError.Param())
|
||||
case "max":
|
||||
errorMessages = append(errorMessages, fieldError.Field()+" must be at most "+fieldError.Param())
|
||||
case "oneof":
|
||||
errorMessages = append(errorMessages, fieldError.Field()+" must be one of: "+fieldError.Param())
|
||||
default:
|
||||
errorMessages = append(errorMessages, fieldError.Field()+" is invalid")
|
||||
}
|
||||
}
|
||||
return errors.New(strings.Join(errorMessages, "; "))
|
||||
}
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user