From d869d83d4b6ac2c34eba1452cf31fb98ce4c8e57 Mon Sep 17 00:00:00 2001 From: Aditya Siregar Date: Mon, 8 Sep 2025 15:21:17 +0700 Subject: [PATCH] add user role --- internal/app/app.go | 7 +- internal/contract/user_contract.go | 11 +-- internal/entities/user.go | 1 + internal/handler/user_handler.go | 2 +- internal/processor/cached_user_processor.go | 2 +- internal/processor/recipient_processor.go | 44 ++++++---- internal/processor/user_processor.go | 17 ++-- internal/processor/user_role_processor.go | 97 +++++++++++++++++++++ internal/repository/letter_repository.go | 8 +- internal/router/health_handler.go | 1 + internal/router/router.go | 11 +-- internal/service/letter_service.go | 10 +-- internal/transformer/user_transformer.go | 1 + internal/validator/user_validator.go | 6 +- 14 files changed, 162 insertions(+), 56 deletions(-) create mode 100644 internal/processor/user_role_processor.go diff --git a/internal/app/app.go b/internal/app/app.go index 6806b83..55fc0f7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -258,12 +258,15 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor novuProvider := processor.NewNovuProvider(novuConfig) notificationProc := processor.NewNotificationProcessor(novuProvider, novuConfig.IncomingLetterWorkflowID) + // Create user role processor + userRoleProc := processor.NewUserRoleProcessor(a.db) + // Create user processor with Novu integration - userProc := processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo) + userProc := processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo, userRoleProc) userProc.SetNovuProcessor(novuProc) // Create cached user processor for auth middleware - cachedUserProc := processor.NewCachedUserProcessor(repos.userRepo, repos.userProfileRepo) + cachedUserProc := processor.NewCachedUserProcessor(repos.userRepo, repos.userProfileRepo, userRoleProc) // Create recipient processor recipientProc := processor.NewRecipientProcessor( diff --git a/internal/contract/user_contract.go b/internal/contract/user_contract.go index a14a4f2..86bcc09 100644 --- a/internal/contract/user_contract.go +++ b/internal/contract/user_contract.go @@ -7,13 +7,10 @@ import ( ) 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"` + 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"` + RoleID *uuid.UUID `json:"role_id,omitempty" validate:"required"` } type UpdateUserRequest struct { diff --git a/internal/entities/user.go b/internal/entities/user.go index 4247b01..f5670fc 100644 --- a/internal/entities/user.go +++ b/internal/entities/user.go @@ -42,6 +42,7 @@ func (p *Permissions) Scan(value interface{}) error { 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"` + Username string `gorm:"not null;size:255" json:"username" 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"` diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index 15679fa..03a8f26 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -48,7 +48,7 @@ func (h *UserHandler) CreateUser(c *gin.Context) { } logger.FromContext(c).Infof("UserHandler::CreateUser -> Successfully created user = %+v", userResponse) - c.JSON(http.StatusCreated, userResponse) + c.JSON(http.StatusOK, contract.BuildSuccessResponse(userResponse)) } func (h *UserHandler) UpdateUser(c *gin.Context) { diff --git a/internal/processor/cached_user_processor.go b/internal/processor/cached_user_processor.go index a0fa70f..98414a9 100644 --- a/internal/processor/cached_user_processor.go +++ b/internal/processor/cached_user_processor.go @@ -26,7 +26,7 @@ type cacheEntry struct { expiresAt time.Time } -func NewCachedUserProcessor(userRepo *repository.UserRepositoryImpl, profileRepo *repository.UserProfileRepository) *CachedUserProcessor { +func NewCachedUserProcessor(userRepo *repository.UserRepositoryImpl, profileRepo *repository.UserProfileRepository, userRoleProc UserRoleProcessor) *CachedUserProcessor { return &CachedUserProcessor{ userRepo: userRepo, profileRepo: profileRepo, diff --git a/internal/processor/recipient_processor.go b/internal/processor/recipient_processor.go index 626addd..4e3f5b7 100644 --- a/internal/processor/recipient_processor.go +++ b/internal/processor/recipient_processor.go @@ -2,6 +2,8 @@ package processor import ( "context" + "eslogad-be/internal/appcontext" + "time" "eslogad-be/internal/entities" "eslogad-be/internal/repository" @@ -50,40 +52,52 @@ func (p *RecipientProcessorImpl) CreateDefaultRecipients(ctx context.Context, le } func (p *RecipientProcessorImpl) CreateRecipients(ctx context.Context, letterID uuid.UUID, departmentIDs []uuid.UUID) ([]entities.LetterIncomingRecipient, error) { - if len(departmentIDs) == 0 { - return []entities.LetterIncomingRecipient{}, nil - } + userCreatorDepartment := appcontext.FromGinContext(ctx).DepartmentID + departmentIDs = append(departmentIDs, userCreatorDepartment) userMemberships, err := p.userDeptRepo.ListActiveByDepartmentIDs(ctx, departmentIDs) if err != nil { return nil, err } - recipients := p.buildUniqueRecipients(letterID, userMemberships) + recipients := p.buildUniqueRecipients(letterID, userMemberships, userCreatorDepartment) - if len(recipients) > 0 { - if err := p.recipientRepo.CreateBulk(ctx, recipients); err != nil { - return nil, err - } + if err := p.recipientRepo.CreateBulk(ctx, recipients); err != nil { + return nil, err } return recipients, nil } -func (p *RecipientProcessorImpl) buildUniqueRecipients(letterID uuid.UUID, userMemberships []repository.UserDepartmentRow) []entities.LetterIncomingRecipient { +func (p *RecipientProcessorImpl) buildUniqueRecipients(letterID uuid.UUID, userMemberships []repository.UserDepartmentRow, userCreatorDepartment uuid.UUID) []entities.LetterIncomingRecipient { var recipients []entities.LetterIncomingRecipient userMap := make(map[string]bool) + now := time.Now() for _, membership := range userMemberships { userIDStr := membership.UserID.String() if !userMap[userIDStr] { - recipients = append(recipients, entities.LetterIncomingRecipient{ - LetterID: letterID, - RecipientUserID: &membership.UserID, - RecipientDepartmentID: &membership.DepartmentID, - Status: entities.RecipientStatusNew, - }) + userID := membership.UserID + departmentID := membership.DepartmentID + + if userCreatorDepartment == membership.DepartmentID { + recipients = append(recipients, entities.LetterIncomingRecipient{ + LetterID: letterID, + RecipientUserID: &userID, + RecipientDepartmentID: &departmentID, + Status: entities.RecipientStatusCompleted, + ReadAt: &now, + CompletedAt: &now, + }) + } else { + recipients = append(recipients, entities.LetterIncomingRecipient{ + LetterID: letterID, + RecipientUserID: &userID, + RecipientDepartmentID: &departmentID, + Status: entities.RecipientStatusNew, + }) + } userMap[userIDStr] = true } } diff --git a/internal/processor/user_processor.go b/internal/processor/user_processor.go index 09ab6be..68821bc 100644 --- a/internal/processor/user_processor.go +++ b/internal/processor/user_processor.go @@ -17,6 +17,7 @@ type UserProcessorImpl struct { userRepo UserRepository profileRepo UserProfileRepository novuProcessor NovuProcessor + userRoleProc UserRoleProcessor } type UserProfileRepository interface { @@ -29,10 +30,12 @@ type UserProfileRepository interface { func NewUserProcessor( userRepo UserRepository, profileRepo UserProfileRepository, + userRoleProc UserRoleProcessor, ) *UserProcessorImpl { return &UserProcessorImpl{ - userRepo: userRepo, - profileRepo: profileRepo, + userRepo: userRepo, + profileRepo: profileRepo, + userRoleProc: userRoleProc, } } @@ -58,7 +61,6 @@ func (p *UserProcessorImpl) CreateUser(ctx context.Context, req *contract.Create return nil, fmt.Errorf("failed to create user: %w", err) } - // create default user profile defaultFullName := userEntity.Name profile := &entities.UserProfile{ UserID: userEntity.ID, @@ -70,11 +72,14 @@ func (p *UserProcessorImpl) CreateUser(ctx context.Context, req *contract.Create } _ = p.profileRepo.Create(ctx, profile) - // Create Novu subscriber + if req.RoleID != nil { + if err := p.userRoleProc.AssignRoleToUser(ctx, userEntity.ID, *req.RoleID); err != nil { + return nil, fmt.Errorf("failed to assign role to user: %w", err) + } + } + if p.novuProcessor != nil { if err := p.novuProcessor.CreateSubscriber(ctx, userEntity); err != nil { - // Log error but don't fail user creation - // You might want to add proper logging here _ = err } } diff --git a/internal/processor/user_role_processor.go b/internal/processor/user_role_processor.go new file mode 100644 index 0000000..49fa8e7 --- /dev/null +++ b/internal/processor/user_role_processor.go @@ -0,0 +1,97 @@ +package processor + +import ( + "context" + "time" + + "eslogad-be/internal/entities" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type UserRoleProcessor interface { + AssignRoleToUser(ctx context.Context, userID, roleID uuid.UUID) error + RemoveRoleFromUser(ctx context.Context, userID, roleID uuid.UUID) error + GetUserRoles(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) + HasRole(ctx context.Context, userID uuid.UUID, roleCode string) (bool, error) +} + +type UserRoleProcessorImpl struct { + db *gorm.DB +} + +func NewUserRoleProcessor(db *gorm.DB) *UserRoleProcessorImpl { + return &UserRoleProcessorImpl{ + db: db, + } +} + +type UserRoleEntry struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"` + UserID uuid.UUID `gorm:"type:uuid;not null"` + RoleID uuid.UUID `gorm:"type:uuid;not null"` + AssignedAt time.Time `gorm:"default:CURRENT_TIMESTAMP"` + RemovedAt *time.Time +} + +func (UserRoleEntry) TableName() string { + return "user_role" +} + +func (p *UserRoleProcessorImpl) AssignRoleToUser(ctx context.Context, userID, roleID uuid.UUID) error { + var existingEntry UserRoleEntry + err := p.db.WithContext(ctx). + Where("user_id = ? AND role_id = ? AND removed_at IS NULL", userID, roleID). + First(&existingEntry).Error + + if err == nil { + return nil + } + + if err != gorm.ErrRecordNotFound { + return err + } + + newEntry := UserRoleEntry{ + UserID: userID, + RoleID: roleID, + AssignedAt: time.Now(), + } + + return p.db.WithContext(ctx).Create(&newEntry).Error +} + +func (p *UserRoleProcessorImpl) RemoveRoleFromUser(ctx context.Context, userID, roleID uuid.UUID) error { + now := time.Now() + return p.db.WithContext(ctx). + Model(&UserRoleEntry{}). + Where("user_id = ? AND role_id = ? AND removed_at IS NULL", userID, roleID). + Update("removed_at", now).Error +} + +func (p *UserRoleProcessorImpl) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) { + var roles []entities.Role + err := p.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 (p *UserRoleProcessorImpl) HasRole(ctx context.Context, userID uuid.UUID, roleCode string) (bool, error) { + var count int64 + err := p.db.WithContext(ctx). + Table("user_role as ur"). + Joins("JOIN roles r ON r.id = ur.role_id"). + Where("ur.user_id = ? AND r.code = ? AND ur.removed_at IS NULL", userID, roleCode). + Count(&count).Error + + if err != nil { + return false, err + } + + return count > 0, nil +} \ No newline at end of file diff --git a/internal/repository/letter_repository.go b/internal/repository/letter_repository.go index 5ea1519..9d72b46 100644 --- a/internal/repository/letter_repository.go +++ b/internal/repository/letter_repository.go @@ -91,7 +91,6 @@ func (r *LetterIncomingRepository) List(ctx context.Context, filter ListIncoming needsGroupBy = true } - // Apply is_read filter if UserID is provided if filter.UserID != nil && filter.IsRead != nil { if !joinedRecipients { query = query.Joins("JOIN letter_incoming_recipients ON letter_incoming_recipients.letter_id = letters_incoming.id") @@ -107,25 +106,20 @@ func (r *LetterIncomingRepository) List(ctx context.Context, filter ListIncoming } } - // Apply is_dispositioned filter if DepartmentID is provided if filter.DepartmentID != nil && filter.IsDispositioned != nil { query = query.Joins("LEFT JOIN letter_incoming_dispositions_department lidd ON lidd.letter_incoming_id = letters_incoming.id AND lidd.department_id = ?", *filter.DepartmentID) if *filter.IsDispositioned { - // Has been dispositioned (status is not 'pending' or record exists with non-pending status) query = query.Where("lidd.id IS NOT NULL AND lidd.status != 'pending'") } else { - // Not yet dispositioned (no record or status is 'pending') query = query.Where("lidd.id IS NULL OR lidd.status = 'pending'") } } - // Apply priority filter if len(filter.PriorityIDs) > 0 { query = query.Where("letters_incoming.priority_id IN ?", filter.PriorityIDs) } - // Apply is_archived filter based on recipient's is_archived field if filter.IsArchived != nil { if *filter.IsArchived { query = query.Where("letter_incoming_recipients.is_archived = ?", true) @@ -137,12 +131,12 @@ func (r *LetterIncomingRepository) List(ctx context.Context, filter ListIncoming if filter.Status != nil { query = query.Where("letters_incoming.status = ?", *filter.Status) } + if filter.Query != nil { q := "%" + *filter.Query + "%" query = query.Where("letters_incoming.subject ILIKE ? OR letters_incoming.reference_number ILIKE ?", q, q) } - // Use GROUP BY instead of DISTINCT to handle joins properly if needsGroupBy { query = query.Group("letters_incoming.id") } diff --git a/internal/router/health_handler.go b/internal/router/health_handler.go index 1b01b3d..4464c3e 100644 --- a/internal/router/health_handler.go +++ b/internal/router/health_handler.go @@ -8,6 +8,7 @@ type HealthHandler interface { type UserHandler interface { ListUsers(c *gin.Context) + CreateUser(c *gin.Context) GetProfile(c *gin.Context) GetUserProfile(c *gin.Context) UpdateProfile(c *gin.Context) diff --git a/internal/router/router.go b/internal/router/router.go index 74e61a6..e61a9bc 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -92,11 +92,12 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { users := v1.Group("/users") users.Use(r.authMiddleware.RequireAuth()) { + users.POST("", r.userHandler.CreateUser) users.GET("", r.userHandler.ListUsers) users.GET("/profile", r.userHandler.GetProfile) users.GET("/:id/profile", r.userHandler.GetUserProfile) users.PUT("/profile", r.userHandler.UpdateProfile) - users.PUT(":id/password", r.userHandler.ChangePassword) + users.PUT("/:id/password", r.userHandler.ChangePassword) users.GET("/titles", r.userHandler.ListTitles) users.GET("/mention", r.userHandler.GetActiveUsersForMention) users.POST("/profile/avatar", r.fileHandler.UploadProfileAvatar) @@ -212,10 +213,10 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { droutes := v1.Group("/disposition-routes") droutes.Use(r.authMiddleware.RequireAuth()) { - droutes.POST("", r.dispRouteHandler.Create) // Create with upsert logic - droutes.PUT("/bulk", r.dispRouteHandler.BulkCreateOrUpdate) // Explicit bulk create/update - droutes.GET("", r.dispRouteHandler.ListAll) // List all routes with details - droutes.GET("/grouped", r.dispRouteHandler.ListGrouped) // List grouped by from_department_id + droutes.POST("", r.dispRouteHandler.Create) // Create with upsert logic + droutes.PUT("/bulk", r.dispRouteHandler.BulkCreateOrUpdate) // Explicit bulk create/update + droutes.GET("", r.dispRouteHandler.ListAll) // List all routes with details + droutes.GET("/grouped", r.dispRouteHandler.ListGrouped) // List grouped by from_department_id droutes.GET("/department", r.dispRouteHandler.ListByFromDept) droutes.GET("/:id", r.dispRouteHandler.Get) droutes.PUT("/:id", r.dispRouteHandler.Update) diff --git a/internal/service/letter_service.go b/internal/service/letter_service.go index bc0bf0f..d27e030 100644 --- a/internal/service/letter_service.go +++ b/internal/service/letter_service.go @@ -3,6 +3,7 @@ package service import ( "context" "eslogad-be/internal/logger" + "fmt" "time" "eslogad-be/internal/appcontext" @@ -134,7 +135,6 @@ func (s *LetterServiceImpl) CreateIncomingLetter(ctx context.Context, req *contr return nil, err } - // Send notifications to all recipients after successful creation if s.notificationProcessor != nil && len(recipients) > 0 { go s.sendLetterNotifications(context.Background(), result, recipients) } @@ -239,19 +239,15 @@ func (s *LetterServiceImpl) addCreatorAsRecipient(ctx context.Context, letterID func (s *LetterServiceImpl) sendLetterNotifications(ctx context.Context, letter *contract.IncomingLetterResponse, recipients []entities.LetterIncomingRecipient) { for _, recipient := range recipients { - // Only send notification to user recipients (not department recipients) - // Also exclude the creator from receiving notifications - if recipient.RecipientUserID != nil && *recipient.RecipientUserID != letter.CreatedBy { - // Use description if available, otherwise use subject + if recipient.Status != "completed" { err := s.notificationProcessor.SendIncomingLetterNotification( ctx, letter.ID, *recipient.RecipientUserID, "Surat Masuk", - letter.Subject) + fmt.Sprintf("%s: %s", letter.SenderInstitution.Name, letter.Subject)) if err != nil { - // Log error but don't fail the entire operation logger.FromContext(ctx).Error("failed to send notification", err) } } diff --git a/internal/transformer/user_transformer.go b/internal/transformer/user_transformer.go index 69da36d..ec50e64 100644 --- a/internal/transformer/user_transformer.go +++ b/internal/transformer/user_transformer.go @@ -12,6 +12,7 @@ func CreateUserRequestToEntity(req *contract.CreateUserRequest, passwordHash str return &entities.User{ Name: req.Name, Email: req.Email, + Username: req.Email, PasswordHash: passwordHash, IsActive: true, } diff --git a/internal/validator/user_validator.go b/internal/validator/user_validator.go index b41e25c..d34d5a7 100644 --- a/internal/validator/user_validator.go +++ b/internal/validator/user_validator.go @@ -37,14 +37,10 @@ func (v *UserValidatorImpl) ValidateCreateUserRequest(req *contract.CreateUserRe return errors.New("password must be at least 6 characters"), constants.MalformedFieldErrorCode } - if strings.TrimSpace(req.Role) == "" { + if req.RoleID == nil { return errors.New("role is required"), constants.MissingFieldErrorCode } - if !isValidUserRole(req.Role) { - return errors.New("invalid user role"), constants.MalformedFieldErrorCode - } - return nil, "" }