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)
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(

View File

@ -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"`
RoleID *uuid.UUID `json:"role_id,omitempty" validate:"required"`
}
type UpdateUserRequest struct {

View File

@ -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"`

View File

@ -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) {

View File

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

View File

@ -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
}
}
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] {
userID := membership.UserID
departmentID := membership.DepartmentID
if userCreatorDepartment == membership.DepartmentID {
recipients = append(recipients, entities.LetterIncomingRecipient{
LetterID: letterID,
RecipientUserID: &membership.UserID,
RecipientDepartmentID: &membership.DepartmentID,
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
}
}

View File

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

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
}
// 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")
}

View File

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

View File

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

View File

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

View File

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

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
}
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, ""
}