Init Eslogad

This commit is contained in:
Aditya Siregar
2025-08-09 15:09:43 +07:00
commit 9e95e8ee5e
96 changed files with 7108 additions and 0 deletions
BIN
View File
Binary file not shown.
+242
View File
@@ -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!** 🎉
+165
View File
@@ -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(),
}
}
+25
View File
@@ -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")
}
+80
View File
@@ -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 ""
}
+81
View File
@@ -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
}
+84
View File
@@ -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)
}
+17
View File
@@ -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,
}
+60
View File
@@ -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")
)
+27
View File
@@ -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"
)
+28
View File
@@ -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
}
+49
View File
@@ -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"`
}
+39
View File
@@ -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
}
+33
View File
@@ -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())
}
+125
View File
@@ -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"`
}
+89
View File
@@ -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
}
+39
View File
@@ -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" }
+18
View File
@@ -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" }
+70
View File
@@ -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
}
+47
View File
@@ -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" }
+168
View File
@@ -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)
}
+13
View File
@@ -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
}
+57
View File
@@ -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)
})
}
+78
View File
@@ -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}))
}
+23
View File
@@ -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!!",
})
}
+306
View File
@@ -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)
}
+23
View File
@@ -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)
}
+16
View File
@@ -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)
}
+134
View File
@@ -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{}{})
}
+170
View File
@@ -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)
}
+4
View File
@@ -0,0 +1,4 @@
package middleware
type AuthProcessor interface {
}
+13
View File
@@ -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)
}
+67
View File
@@ -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)
}
+22
View File
@@ -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()
}
}
+21
View File
@@ -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()
})
}
+17
View File
@@ -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()
}
}
+24
View File
@@ -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,
)
})
}
+69
View File
@@ -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()
})
}
+30
View File
@@ -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()
}
}
+35
View File
@@ -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()),
})
}
}
+40
View File
@@ -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 ""
}
+12
View File
@@ -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)
}
+251
View File
@@ -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
}
+25
View File
@@ -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)
}
+25
View File
@@ -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
}
+141
View File
@@ -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
}
+9
View File
@@ -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)
}
+20
View File
@@ -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)
}
+13
View File
@@ -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
}
+81
View File
@@ -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)
}
}
}
+194
View File
@@ -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
}
+92
View File
@@ -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 ""
}
}
+27
View File
@@ -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)
}
+91
View File
@@ -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
}
+172
View File
@@ -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
}
+62
View File
@@ -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
}
+72
View File
@@ -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
}
+211
View File
@@ -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"))
}
}
})
}
}
+49
View File
@@ -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
}
+153
View File
@@ -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, ""
}
+61
View File
@@ -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
}