add user role

This commit is contained in:
Aditya Siregar 2025-09-08 15:21:17 +07:00
parent 2319019eb2
commit d869d83d4b
14 changed files with 162 additions and 56 deletions

View File

@ -258,12 +258,15 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
novuProvider := processor.NewNovuProvider(novuConfig) novuProvider := processor.NewNovuProvider(novuConfig)
notificationProc := processor.NewNotificationProcessor(novuProvider, novuConfig.IncomingLetterWorkflowID) notificationProc := processor.NewNotificationProcessor(novuProvider, novuConfig.IncomingLetterWorkflowID)
// Create user role processor
userRoleProc := processor.NewUserRoleProcessor(a.db)
// Create user processor with Novu integration // Create user processor with Novu integration
userProc := processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo) userProc := processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo, userRoleProc)
userProc.SetNovuProcessor(novuProc) userProc.SetNovuProcessor(novuProc)
// Create cached user processor for auth middleware // 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 // Create recipient processor
recipientProc := processor.NewRecipientProcessor( recipientProc := processor.NewRecipientProcessor(

View File

@ -7,13 +7,10 @@ import (
) )
type CreateUserRequest struct { type CreateUserRequest struct {
OrganizationID uuid.UUID `json:"organization_id" validate:"required"` Name string `json:"name" validate:"required,min=1,max=255"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` Email string `json:"email" validate:"required,email"`
Name string `json:"name" validate:"required,min=1,max=255"` Password string `json:"password" validate:"required,min=6"`
Email string `json:"email" validate:"required,email"` RoleID *uuid.UUID `json:"role_id,omitempty" validate:"required"`
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 { type UpdateUserRequest struct {

View File

@ -42,6 +42,7 @@ func (p *Permissions) Scan(value interface{}) error {
type User struct { type User struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` 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"` 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"` Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"`
PasswordHash string `gorm:"not null;size:255" json:"-"` PasswordHash string `gorm:"not null;size:255" json:"-"`
IsActive bool `gorm:"default:true" json:"is_active"` IsActive bool `gorm:"default:true" json:"is_active"`

View File

@ -48,7 +48,7 @@ func (h *UserHandler) CreateUser(c *gin.Context) {
} }
logger.FromContext(c).Infof("UserHandler::CreateUser -> Successfully created user = %+v", userResponse) 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) { func (h *UserHandler) UpdateUser(c *gin.Context) {

View File

@ -26,7 +26,7 @@ type cacheEntry struct {
expiresAt time.Time 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{ return &CachedUserProcessor{
userRepo: userRepo, userRepo: userRepo,
profileRepo: profileRepo, profileRepo: profileRepo,

View File

@ -2,6 +2,8 @@ package processor
import ( import (
"context" "context"
"eslogad-be/internal/appcontext"
"time"
"eslogad-be/internal/entities" "eslogad-be/internal/entities"
"eslogad-be/internal/repository" "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) { func (p *RecipientProcessorImpl) CreateRecipients(ctx context.Context, letterID uuid.UUID, departmentIDs []uuid.UUID) ([]entities.LetterIncomingRecipient, error) {
if len(departmentIDs) == 0 { userCreatorDepartment := appcontext.FromGinContext(ctx).DepartmentID
return []entities.LetterIncomingRecipient{}, nil departmentIDs = append(departmentIDs, userCreatorDepartment)
}
userMemberships, err := p.userDeptRepo.ListActiveByDepartmentIDs(ctx, departmentIDs) userMemberships, err := p.userDeptRepo.ListActiveByDepartmentIDs(ctx, departmentIDs)
if err != nil { if err != nil {
return nil, err 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 {
if err := p.recipientRepo.CreateBulk(ctx, recipients); err != nil { return nil, err
return nil, err
}
} }
return recipients, nil 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 var recipients []entities.LetterIncomingRecipient
userMap := make(map[string]bool) userMap := make(map[string]bool)
now := time.Now()
for _, membership := range userMemberships { for _, membership := range userMemberships {
userIDStr := membership.UserID.String() userIDStr := membership.UserID.String()
if !userMap[userIDStr] { if !userMap[userIDStr] {
recipients = append(recipients, entities.LetterIncomingRecipient{ userID := membership.UserID
LetterID: letterID, departmentID := membership.DepartmentID
RecipientUserID: &membership.UserID,
RecipientDepartmentID: &membership.DepartmentID, if userCreatorDepartment == membership.DepartmentID {
Status: entities.RecipientStatusNew, 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 userMap[userIDStr] = true
} }
} }

View File

@ -17,6 +17,7 @@ type UserProcessorImpl struct {
userRepo UserRepository userRepo UserRepository
profileRepo UserProfileRepository profileRepo UserProfileRepository
novuProcessor NovuProcessor novuProcessor NovuProcessor
userRoleProc UserRoleProcessor
} }
type UserProfileRepository interface { type UserProfileRepository interface {
@ -29,10 +30,12 @@ type UserProfileRepository interface {
func NewUserProcessor( func NewUserProcessor(
userRepo UserRepository, userRepo UserRepository,
profileRepo UserProfileRepository, profileRepo UserProfileRepository,
userRoleProc UserRoleProcessor,
) *UserProcessorImpl { ) *UserProcessorImpl {
return &UserProcessorImpl{ return &UserProcessorImpl{
userRepo: userRepo, userRepo: userRepo,
profileRepo: profileRepo, 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) return nil, fmt.Errorf("failed to create user: %w", err)
} }
// create default user profile
defaultFullName := userEntity.Name defaultFullName := userEntity.Name
profile := &entities.UserProfile{ profile := &entities.UserProfile{
UserID: userEntity.ID, UserID: userEntity.ID,
@ -70,11 +72,14 @@ func (p *UserProcessorImpl) CreateUser(ctx context.Context, req *contract.Create
} }
_ = p.profileRepo.Create(ctx, profile) _ = 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 p.novuProcessor != nil {
if err := p.novuProcessor.CreateSubscriber(ctx, userEntity); err != 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 _ = err
} }
} }

View File

@ -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
}

View File

@ -91,7 +91,6 @@ func (r *LetterIncomingRepository) List(ctx context.Context, filter ListIncoming
needsGroupBy = true needsGroupBy = true
} }
// Apply is_read filter if UserID is provided
if filter.UserID != nil && filter.IsRead != nil { if filter.UserID != nil && filter.IsRead != nil {
if !joinedRecipients { if !joinedRecipients {
query = query.Joins("JOIN letter_incoming_recipients ON letter_incoming_recipients.letter_id = letters_incoming.id") 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 { 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) 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 { 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'") query = query.Where("lidd.id IS NOT NULL AND lidd.status != 'pending'")
} else { } else {
// Not yet dispositioned (no record or status is 'pending')
query = query.Where("lidd.id IS NULL OR lidd.status = 'pending'") query = query.Where("lidd.id IS NULL OR lidd.status = 'pending'")
} }
} }
// Apply priority filter
if len(filter.PriorityIDs) > 0 { if len(filter.PriorityIDs) > 0 {
query = query.Where("letters_incoming.priority_id IN ?", filter.PriorityIDs) 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 != nil {
if *filter.IsArchived { if *filter.IsArchived {
query = query.Where("letter_incoming_recipients.is_archived = ?", true) 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 { if filter.Status != nil {
query = query.Where("letters_incoming.status = ?", *filter.Status) query = query.Where("letters_incoming.status = ?", *filter.Status)
} }
if filter.Query != nil { if filter.Query != nil {
q := "%" + *filter.Query + "%" q := "%" + *filter.Query + "%"
query = query.Where("letters_incoming.subject ILIKE ? OR letters_incoming.reference_number ILIKE ?", q, q) 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 { if needsGroupBy {
query = query.Group("letters_incoming.id") query = query.Group("letters_incoming.id")
} }

View File

@ -8,6 +8,7 @@ type HealthHandler interface {
type UserHandler interface { type UserHandler interface {
ListUsers(c *gin.Context) ListUsers(c *gin.Context)
CreateUser(c *gin.Context)
GetProfile(c *gin.Context) GetProfile(c *gin.Context)
GetUserProfile(c *gin.Context) GetUserProfile(c *gin.Context)
UpdateProfile(c *gin.Context) UpdateProfile(c *gin.Context)

View File

@ -92,11 +92,12 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
users := v1.Group("/users") users := v1.Group("/users")
users.Use(r.authMiddleware.RequireAuth()) users.Use(r.authMiddleware.RequireAuth())
{ {
users.POST("", r.userHandler.CreateUser)
users.GET("", r.userHandler.ListUsers) users.GET("", r.userHandler.ListUsers)
users.GET("/profile", r.userHandler.GetProfile) users.GET("/profile", r.userHandler.GetProfile)
users.GET("/:id/profile", r.userHandler.GetUserProfile) users.GET("/:id/profile", r.userHandler.GetUserProfile)
users.PUT("/profile", r.userHandler.UpdateProfile) 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("/titles", r.userHandler.ListTitles)
users.GET("/mention", r.userHandler.GetActiveUsersForMention) users.GET("/mention", r.userHandler.GetActiveUsersForMention)
users.POST("/profile/avatar", r.fileHandler.UploadProfileAvatar) users.POST("/profile/avatar", r.fileHandler.UploadProfileAvatar)
@ -212,10 +213,10 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
droutes := v1.Group("/disposition-routes") droutes := v1.Group("/disposition-routes")
droutes.Use(r.authMiddleware.RequireAuth()) droutes.Use(r.authMiddleware.RequireAuth())
{ {
droutes.POST("", r.dispRouteHandler.Create) // Create with upsert logic droutes.POST("", r.dispRouteHandler.Create) // Create with upsert logic
droutes.PUT("/bulk", r.dispRouteHandler.BulkCreateOrUpdate) // Explicit bulk create/update droutes.PUT("/bulk", r.dispRouteHandler.BulkCreateOrUpdate) // Explicit bulk create/update
droutes.GET("", r.dispRouteHandler.ListAll) // List all routes with details droutes.GET("", r.dispRouteHandler.ListAll) // List all routes with details
droutes.GET("/grouped", r.dispRouteHandler.ListGrouped) // List grouped by from_department_id droutes.GET("/grouped", r.dispRouteHandler.ListGrouped) // List grouped by from_department_id
droutes.GET("/department", r.dispRouteHandler.ListByFromDept) droutes.GET("/department", r.dispRouteHandler.ListByFromDept)
droutes.GET("/:id", r.dispRouteHandler.Get) droutes.GET("/:id", r.dispRouteHandler.Get)
droutes.PUT("/:id", r.dispRouteHandler.Update) droutes.PUT("/:id", r.dispRouteHandler.Update)

View File

@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"eslogad-be/internal/logger" "eslogad-be/internal/logger"
"fmt"
"time" "time"
"eslogad-be/internal/appcontext" "eslogad-be/internal/appcontext"
@ -134,7 +135,6 @@ func (s *LetterServiceImpl) CreateIncomingLetter(ctx context.Context, req *contr
return nil, err return nil, err
} }
// Send notifications to all recipients after successful creation
if s.notificationProcessor != nil && len(recipients) > 0 { if s.notificationProcessor != nil && len(recipients) > 0 {
go s.sendLetterNotifications(context.Background(), result, recipients) 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) { func (s *LetterServiceImpl) sendLetterNotifications(ctx context.Context, letter *contract.IncomingLetterResponse, recipients []entities.LetterIncomingRecipient) {
for _, recipient := range recipients { for _, recipient := range recipients {
// Only send notification to user recipients (not department recipients) if recipient.Status != "completed" {
// Also exclude the creator from receiving notifications
if recipient.RecipientUserID != nil && *recipient.RecipientUserID != letter.CreatedBy {
// Use description if available, otherwise use subject
err := s.notificationProcessor.SendIncomingLetterNotification( err := s.notificationProcessor.SendIncomingLetterNotification(
ctx, ctx,
letter.ID, letter.ID,
*recipient.RecipientUserID, *recipient.RecipientUserID,
"Surat Masuk", "Surat Masuk",
letter.Subject) fmt.Sprintf("%s: %s", letter.SenderInstitution.Name, letter.Subject))
if err != nil { if err != nil {
// Log error but don't fail the entire operation
logger.FromContext(ctx).Error("failed to send notification", err) logger.FromContext(ctx).Error("failed to send notification", err)
} }
} }

View File

@ -12,6 +12,7 @@ func CreateUserRequestToEntity(req *contract.CreateUserRequest, passwordHash str
return &entities.User{ return &entities.User{
Name: req.Name, Name: req.Name,
Email: req.Email, Email: req.Email,
Username: req.Email,
PasswordHash: passwordHash, PasswordHash: passwordHash,
IsActive: true, IsActive: true,
} }

View File

@ -37,14 +37,10 @@ func (v *UserValidatorImpl) ValidateCreateUserRequest(req *contract.CreateUserRe
return errors.New("password must be at least 6 characters"), constants.MalformedFieldErrorCode 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 return errors.New("role is required"), constants.MissingFieldErrorCode
} }
if !isValidUserRole(req.Role) {
return errors.New("invalid user role"), constants.MalformedFieldErrorCode
}
return nil, "" return nil, ""
} }