From de60983e4efebd7e49e8156f8caa17ab38f72b13 Mon Sep 17 00:00:00 2001 From: Aditya Siregar Date: Sat, 9 Aug 2025 23:44:03 +0700 Subject: [PATCH] Init --- internal/app/app.go | 10 +- internal/contract/common.go | 6 + internal/contract/user_contract.go | 31 +++-- internal/entities/department.go | 18 +++ .../entities/letter_incoming_recipient.go | 28 ++++ internal/entities/setting.go | 14 ++ internal/entities/user.go | 15 ++- internal/handler/user_handler.go | 14 ++ internal/processor/letter_processor.go | 82 +++++++++-- internal/processor/user_processor.go | 41 ++++-- internal/processor/user_repository.go | 7 +- internal/repository/letter_repository.go | 28 ++++ internal/repository/master_repository.go | 13 ++ .../repository/user_department_repository.go | 34 +++++ internal/repository/user_repository.go | 110 +++++++++++---- internal/router/router.go | 3 +- internal/service/auth_service.go | 9 +- internal/service/user_processor.go | 6 +- internal/service/user_service.go | 11 +- internal/transformer/common_transformer.go | 6 +- internal/transformer/user_transformer.go | 8 +- migrations/000002_seed_user_data.up.sql | 127 ++++++++---------- migrations/000011_settings.down.sql | 4 + migrations/000011_settings.up.sql | 21 +++ 24 files changed, 493 insertions(+), 153 deletions(-) create mode 100644 internal/entities/department.go create mode 100644 internal/entities/letter_incoming_recipient.go create mode 100644 internal/entities/setting.go create mode 100644 internal/repository/user_department_repository.go create mode 100644 migrations/000011_settings.down.sql create mode 100644 migrations/000011_settings.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index 331ec88..45c7f1b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -121,6 +121,10 @@ type repositories struct { letterDispActionSelRepo *repository.LetterDispositionActionSelectionRepository dispositionNoteRepo *repository.DispositionNoteRepository letterDiscussionRepo *repository.LetterDiscussionRepository + settingRepo *repository.AppSettingRepository + recipientRepo *repository.LetterIncomingRecipientRepository + departmentRepo *repository.DepartmentRepository + userDeptRepo *repository.UserDepartmentRepository } func (a *App) initRepositories() *repositories { @@ -141,6 +145,10 @@ func (a *App) initRepositories() *repositories { letterDispActionSelRepo: repository.NewLetterDispositionActionSelectionRepository(a.db), dispositionNoteRepo: repository.NewDispositionNoteRepository(a.db), letterDiscussionRepo: repository.NewLetterDiscussionRepository(a.db), + settingRepo: repository.NewAppSettingRepository(a.db), + recipientRepo: repository.NewLetterIncomingRecipientRepository(a.db), + departmentRepo: repository.NewDepartmentRepository(a.db), + userDeptRepo: repository.NewUserDepartmentRepository(a.db), } } @@ -155,7 +163,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor activity := processor.NewActivityLogProcessor(repos.activityLogRepo) return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo), - letterProcessor: processor.NewLetterProcessor(repos.letterRepo, repos.letterAttachRepo, txMgr, activity, repos.letterDispositionRepo, repos.letterDispActionSelRepo, repos.dispositionNoteRepo, repos.letterDiscussionRepo), + letterProcessor: processor.NewLetterProcessor(repos.letterRepo, repos.letterAttachRepo, txMgr, activity, repos.letterDispositionRepo, repos.letterDispActionSelRepo, repos.dispositionNoteRepo, repos.letterDiscussionRepo, repos.settingRepo, repos.recipientRepo, repos.departmentRepo, repos.userDeptRepo), activityLogger: activity, } } diff --git a/internal/contract/common.go b/internal/contract/common.go index 2bd94a6..e0006c9 100644 --- a/internal/contract/common.go +++ b/internal/contract/common.go @@ -2,6 +2,12 @@ package contract import "time" +const ( + SettingIncomingLetterPrefix = "INCOMING_LETTER_PREFIX" + SettingIncomingLetterSequence = "INCOMING_LETTER_SEQUENCE" + SettingIncomingLetterRecipients = "INCOMING_LETTER_RECIPIENTS" +) + type ErrorResponse struct { Error string `json:"error"` Message string `json:"message"` diff --git a/internal/contract/user_contract.go b/internal/contract/user_contract.go index bd0f340..970c173 100644 --- a/internal/contract/user_contract.go +++ b/internal/contract/user_contract.go @@ -39,21 +39,23 @@ type LoginRequest struct { } 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"` + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + User UserResponse `json:"user"` + Roles []RoleResponse `json:"roles"` + Permissions []string `json:"permissions"` + Departments []DepartmentResponse `json:"departments"` } 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"` + 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"` + Roles []RoleResponse `json:"roles,omitempty"` + Profile *UserProfileResponse `json:"profile,omitempty"` } type ListUsersRequest struct { @@ -61,6 +63,8 @@ type ListUsersRequest struct { Limit int `json:"limit" validate:"min=1,max=100"` Role *string `json:"role,omitempty"` IsActive *bool `json:"is_active,omitempty"` + Search *string `json:"search,omitempty"` + RoleCode *string `json:"role_code,omitempty"` } type ListUsersResponse struct { @@ -74,7 +78,7 @@ type RoleResponse struct { Code string `json:"code"` } -type PositionResponse struct { +type DepartmentResponse struct { ID uuid.UUID `json:"id"` Name string `json:"name"` Code string `json:"code"` @@ -97,6 +101,7 @@ type UserProfileResponse struct { LastSeenAt *time.Time `json:"last_seen_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + Roles []RoleResponse `json:"roles"` } type UpdateUserProfileRequest struct { diff --git a/internal/entities/department.go b/internal/entities/department.go new file mode 100644 index 0000000..e93f7b3 --- /dev/null +++ b/internal/entities/department.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +type Department struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Name string `gorm:"not null" json:"name"` + Code string `json:"code,omitempty"` + Path string `gorm:"not null" json:"path"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (Department) TableName() string { return "departments" } diff --git a/internal/entities/letter_incoming_recipient.go b/internal/entities/letter_incoming_recipient.go new file mode 100644 index 0000000..bb16799 --- /dev/null +++ b/internal/entities/letter_incoming_recipient.go @@ -0,0 +1,28 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +type LetterIncomingRecipientStatus string + +const ( + RecipientStatusNew LetterIncomingRecipientStatus = "new" + RecipientStatusRead LetterIncomingRecipientStatus = "read" + RecipientStatusCompleted LetterIncomingRecipientStatus = "completed" +) + +type LetterIncomingRecipient struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` + RecipientUserID *uuid.UUID `json:"recipient_user_id,omitempty"` + RecipientDepartmentID *uuid.UUID `json:"recipient_department_id,omitempty"` + Status LetterIncomingRecipientStatus `gorm:"not null;default:'new'" json:"status"` + ReadAt *time.Time `json:"read_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +func (LetterIncomingRecipient) TableName() string { return "letter_incoming_recipients" } diff --git a/internal/entities/setting.go b/internal/entities/setting.go new file mode 100644 index 0000000..7fd5032 --- /dev/null +++ b/internal/entities/setting.go @@ -0,0 +1,14 @@ +package entities + +import ( + "time" +) + +type AppSetting struct { + Key string `gorm:"primaryKey;size:100" json:"key"` + Value JSONB `gorm:"type:jsonb;default:'{}'" json:"value"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (AppSetting) TableName() string { return "app_settings" } diff --git a/internal/entities/user.go b/internal/entities/user.go index 838b074..ebb7ff4 100644 --- a/internal/entities/user.go +++ b/internal/entities/user.go @@ -40,13 +40,14 @@ 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"` - 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"` + 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"` + Profile *UserProfile `gorm:"foreignKey:UserID;references:ID" json:"profile,omitempty"` } func (u *User) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index 4ca7c84..f200acc 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -166,10 +166,24 @@ func (h *UserHandler) ListUsers(c *gin.Context) { } } + var roleParam *string if role := c.Query("role"); role != "" { + roleParam = &role req.Role = &role } + if roleCode := c.Query("role_code"); roleCode != "" { + req.RoleCode = &roleCode + } + + if req.RoleCode == nil && roleParam != nil { + req.RoleCode = roleParam + } + + if search := c.Query("search"); search != "" { + req.Search = &search + } + if isActiveStr := c.Query("is_active"); isActiveStr != "" { if isActive, err := strconv.ParseBool(isActiveStr); err == nil { req.IsActive = &isActive diff --git a/internal/processor/letter_processor.go b/internal/processor/letter_processor.go index 02ac1f9..b128ab9 100644 --- a/internal/processor/letter_processor.go +++ b/internal/processor/letter_processor.go @@ -2,6 +2,7 @@ package processor import ( "context" + "fmt" "time" "eslogad-be/internal/appcontext" @@ -24,16 +25,37 @@ type LetterProcessorImpl struct { dispositionNoteRepo *repository.DispositionNoteRepository // discussion repo discussionRepo *repository.LetterDiscussionRepository + // settings and recipients + settingRepo *repository.AppSettingRepository + recipientRepo *repository.LetterIncomingRecipientRepository + departmentRepo *repository.DepartmentRepository + userDeptRepo *repository.UserDepartmentRepository } -func NewLetterProcessor(letterRepo *repository.LetterIncomingRepository, attachRepo *repository.LetterIncomingAttachmentRepository, txManager *repository.TxManager, activity *ActivityLogProcessorImpl, dispRepo *repository.LetterDispositionRepository, dispSelRepo *repository.LetterDispositionActionSelectionRepository, noteRepo *repository.DispositionNoteRepository, discussionRepo *repository.LetterDiscussionRepository) *LetterProcessorImpl { - return &LetterProcessorImpl{letterRepo: letterRepo, attachRepo: attachRepo, txManager: txManager, activity: activity, dispositionRepo: dispRepo, dispositionActionSelRepo: dispSelRepo, dispositionNoteRepo: noteRepo, discussionRepo: discussionRepo} +func NewLetterProcessor(letterRepo *repository.LetterIncomingRepository, attachRepo *repository.LetterIncomingAttachmentRepository, txManager *repository.TxManager, activity *ActivityLogProcessorImpl, dispRepo *repository.LetterDispositionRepository, dispSelRepo *repository.LetterDispositionActionSelectionRepository, noteRepo *repository.DispositionNoteRepository, discussionRepo *repository.LetterDiscussionRepository, settingRepo *repository.AppSettingRepository, recipientRepo *repository.LetterIncomingRecipientRepository, departmentRepo *repository.DepartmentRepository, userDeptRepo *repository.UserDepartmentRepository) *LetterProcessorImpl { + return &LetterProcessorImpl{letterRepo: letterRepo, attachRepo: attachRepo, txManager: txManager, activity: activity, dispositionRepo: dispRepo, dispositionActionSelRepo: dispSelRepo, dispositionNoteRepo: noteRepo, discussionRepo: discussionRepo, settingRepo: settingRepo, recipientRepo: recipientRepo, departmentRepo: departmentRepo, userDeptRepo: userDeptRepo} } func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *contract.CreateIncomingLetterRequest) (*contract.IncomingLetterResponse, error) { var result *contract.IncomingLetterResponse err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { userID := appcontext.FromGinContext(txCtx).UserID + + prefix := "ESLI" + seq := 0 + if s, err := p.settingRepo.Get(txCtx, contract.SettingIncomingLetterPrefix); err == nil { + if v, ok := s.Value["value"].(string); ok && v != "" { + prefix = v + } + } + if s, err := p.settingRepo.Get(txCtx, contract.SettingIncomingLetterSequence); err == nil { + if v, ok := s.Value["value"].(float64); ok { + seq = int(v) + } + } + seq = seq + 1 + letterNumber := fmt.Sprintf("%s%04d", prefix, seq) + entity := &entities.LetterIncoming{ ReferenceNumber: req.ReferenceNumber, Subject: req.Subject, @@ -45,26 +67,63 @@ func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *con Status: entities.LetterIncomingStatusNew, CreatedBy: userID, } + entity.LetterNumber = letterNumber if err := p.letterRepo.Create(txCtx, entity); err != nil { return err } + _ = p.settingRepo.Upsert(txCtx, contract.SettingIncomingLetterSequence, entities.JSONB{"value": seq}) + + defaultDeptCodes := []string{} + if s, err := p.settingRepo.Get(txCtx, contract.SettingIncomingLetterRecipients); err == nil { + if arr, ok := s.Value["department_codes"].([]interface{}); ok { + for _, it := range arr { + if str, ok := it.(string); ok { + defaultDeptCodes = append(defaultDeptCodes, str) + } + } + } + } + + // resolve department codes to ids using repository + depIDs := make([]uuid.UUID, 0, len(defaultDeptCodes)) + for _, code := range defaultDeptCodes { + dep, err := p.departmentRepo.GetByCode(txCtx, code) + if err != nil { + continue + } + depIDs = append(depIDs, dep.ID) + } + // query user memberships for all departments at once + userMemberships, _ := p.userDeptRepo.ListActiveByDepartmentIDs(txCtx, depIDs) + // build recipients: one department recipient per department + one user recipient per membership + recipients := make([]entities.LetterIncomingRecipient, 0, len(depIDs)+len(userMemberships)) + // department recipients + for _, depID := range depIDs { + id := depID + recipients = append(recipients, entities.LetterIncomingRecipient{LetterID: entity.ID, RecipientDepartmentID: &id, Status: entities.RecipientStatusNew}) + } + // user recipients + for _, row := range userMemberships { + uid := row.UserID + recipients = append(recipients, entities.LetterIncomingRecipient{LetterID: entity.ID, RecipientUserID: &uid, Status: entities.RecipientStatusNew}) + } + if len(recipients) > 0 { + if err := p.recipientRepo.CreateBulk(txCtx, recipients); err != nil { + return err + } + } + if p.activity != nil { action := "letter.created" - if err := p.activity.Log(txCtx, entity.ID, action, &userID, nil, nil, nil, nil, nil, map[string]interface{}{}); err != nil { + if err := p.activity.Log(txCtx, entity.ID, action, &userID, nil, nil, nil, nil, nil, map[string]interface{}{"letter_number": letterNumber}); err != nil { return err } } attachments := make([]entities.LetterIncomingAttachment, 0, len(req.Attachments)) for _, a := range req.Attachments { - attachments = append(attachments, entities.LetterIncomingAttachment{ - LetterID: entity.ID, - FileURL: a.FileURL, - FileName: a.FileName, - FileType: a.FileType, - UploadedBy: &userID, - }) + attachments = append(attachments, entities.LetterIncomingAttachment{LetterID: entity.ID, FileURL: a.FileURL, FileName: a.FileName, FileType: a.FileType, UploadedBy: &userID}) } if len(attachments) > 0 { if err := p.attachRepo.CreateBulk(txCtx, attachments); err != nil { @@ -85,7 +144,6 @@ func (p *LetterProcessorImpl) CreateIncomingLetter(ctx context.Context, req *con result = transformer.LetterEntityToContract(entity, savedAttachments) return nil }) - if err != nil { return nil, err } @@ -267,7 +325,7 @@ func (p *LetterProcessorImpl) CreateDiscussion(ctx context.Context, letterID uui return err } if p.activity != nil { - action := "discussion.created" + action := "reference_numberdiscussion.created" tgt := "discussion" ctxMap := map[string]interface{}{"message": req.Message, "parent_id": req.ParentID} if err := p.activity.Log(txCtx, letterID, action, &userID, nil, &tgt, &disc.ID, nil, nil, ctxMap); err != nil { diff --git a/internal/processor/user_processor.go b/internal/processor/user_processor.go index f253bc1..a241081 100644 --- a/internal/processor/user_processor.go +++ b/internal/processor/user_processor.go @@ -110,8 +110,13 @@ func (p *UserProcessorImpl) GetUserByID(ctx context.Context, id uuid.UUID) (*con if err != nil { return nil, fmt.Errorf("user not found: %w", err) } - - return transformer.EntityToContract(user), nil + resp := transformer.EntityToContract(user) + if resp != nil { + if roles, err := p.userRepo.GetRolesByUserID(ctx, resp.ID); err == nil { + resp.Roles = transformer.RolesToContract(roles) + } + } + return resp, nil } func (p *UserProcessorImpl) GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error) { @@ -123,17 +128,35 @@ func (p *UserProcessorImpl) GetUserByEmail(ctx context.Context, email string) (* return transformer.EntityToContract(user), nil } -func (p *UserProcessorImpl) ListUsers(ctx context.Context, page, limit int) ([]contract.UserResponse, int, error) { +func (p *UserProcessorImpl) ListUsersWithFilters(ctx context.Context, req *contract.ListUsersRequest) ([]contract.UserResponse, int, error) { + page := req.Page + if page <= 0 { + page = 1 + } + limit := req.Limit + if limit <= 0 { + limit = 10 + } offset := (page - 1) * limit - filters := map[string]interface{}{} - - users, totalCount, err := p.userRepo.List(ctx, filters, limit, offset) + users, totalCount, err := p.userRepo.ListWithFilters(ctx, req.Search, req.RoleCode, req.IsActive, limit, offset) if err != nil { return nil, 0, fmt.Errorf("failed to get users: %w", err) } responses := transformer.EntitiesToContracts(users) + userIDs := make([]uuid.UUID, 0, len(responses)) + for i := range responses { + userIDs = append(userIDs, responses[i].ID) + } + rolesMap, err := p.userRepo.GetRolesByUserIDs(ctx, userIDs) + if err == nil { + for i := range responses { + if roles, ok := rolesMap[responses[i].ID]; ok { + responses[i].Roles = transformer.RolesToContract(roles) + } + } + } return responses, int(totalCount), nil } @@ -219,12 +242,12 @@ func (p *UserProcessorImpl) GetUserPermissionCodes(ctx context.Context, userID u return codes, nil } -func (p *UserProcessorImpl) GetUserPositions(ctx context.Context, userID uuid.UUID) ([]contract.PositionResponse, error) { - positions, err := p.userRepo.GetPositionsByUserID(ctx, userID) +func (p *UserProcessorImpl) GetUserDepartments(ctx context.Context, userID uuid.UUID) ([]contract.DepartmentResponse, error) { + departments, err := p.userRepo.GetDepartmentsByUserID(ctx, userID) if err != nil { return nil, err } - return transformer.PositionsToContract(positions), nil + return transformer.DepartmentsToContract(departments), nil } func (p *UserProcessorImpl) GetUserProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) { diff --git a/internal/processor/user_repository.go b/internal/processor/user_repository.go index ea0fb66..a9a2efa 100644 --- a/internal/processor/user_repository.go +++ b/internal/processor/user_repository.go @@ -3,6 +3,7 @@ package processor import ( "context" "eslogad-be/internal/entities" + "github.com/google/uuid" ) @@ -21,5 +22,9 @@ type UserRepository interface { 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) + GetDepartmentsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Department, error) + + // New optimized helpers + GetRolesByUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]entities.Role, error) + ListWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]*entities.User, int64, error) } diff --git a/internal/repository/letter_repository.go b/internal/repository/letter_repository.go index 6c0d58e..6fedabc 100644 --- a/internal/repository/letter_repository.go +++ b/internal/repository/letter_repository.go @@ -178,3 +178,31 @@ func (r *LetterDiscussionRepository) Update(ctx context.Context, e *entities.Let Where("id = ?", e.ID). Updates(map[string]interface{}{"message": e.Message, "mentions": e.Mentions, "edited_at": e.EditedAt}).Error } + +type AppSettingRepository struct{ db *gorm.DB } + +func NewAppSettingRepository(db *gorm.DB) *AppSettingRepository { return &AppSettingRepository{db: db} } +func (r *AppSettingRepository) Get(ctx context.Context, key string) (*entities.AppSetting, error) { + db := DBFromContext(ctx, r.db) + var e entities.AppSetting + if err := db.WithContext(ctx).First(&e, "key = ?", key).Error; err != nil { + return nil, err + } + return &e, nil +} +func (r *AppSettingRepository) Upsert(ctx context.Context, key string, value entities.JSONB) error { + db := DBFromContext(ctx, r.db) + return db.WithContext(ctx).Exec("INSERT INTO app_settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP", key, value).Error +} + +// recipients + +type LetterIncomingRecipientRepository struct{ db *gorm.DB } + +func NewLetterIncomingRecipientRepository(db *gorm.DB) *LetterIncomingRecipientRepository { + return &LetterIncomingRecipientRepository{db: db} +} +func (r *LetterIncomingRecipientRepository) CreateBulk(ctx context.Context, recs []entities.LetterIncomingRecipient) error { + db := DBFromContext(ctx, r.db) + return db.WithContext(ctx).Create(&recs).Error +} diff --git a/internal/repository/master_repository.go b/internal/repository/master_repository.go index d5376e8..d4ec39f 100644 --- a/internal/repository/master_repository.go +++ b/internal/repository/master_repository.go @@ -112,3 +112,16 @@ func (r *DispositionActionRepository) Get(ctx context.Context, id uuid.UUID) (*e } return &e, nil } + +type DepartmentRepository struct{ db *gorm.DB } + +func NewDepartmentRepository(db *gorm.DB) *DepartmentRepository { return &DepartmentRepository{db: db} } + +func (r *DepartmentRepository) GetByCode(ctx context.Context, code string) (*entities.Department, error) { + db := DBFromContext(ctx, r.db) + var dep entities.Department + if err := db.WithContext(ctx).Where("code = ?", code).First(&dep).Error; err != nil { + return nil, err + } + return &dep, nil +} diff --git a/internal/repository/user_department_repository.go b/internal/repository/user_department_repository.go new file mode 100644 index 0000000..cb06aa4 --- /dev/null +++ b/internal/repository/user_department_repository.go @@ -0,0 +1,34 @@ +package repository + +import ( + "context" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type UserDepartmentRepository struct{ db *gorm.DB } + +func NewUserDepartmentRepository(db *gorm.DB) *UserDepartmentRepository { + return &UserDepartmentRepository{db: db} +} + +type userDepartmentRow struct { + UserID uuid.UUID `gorm:"column:user_id"` + DepartmentID uuid.UUID `gorm:"column:department_id"` +} + +// ListActiveByDepartmentIDs returns active user-department memberships for given department IDs. +func (r *UserDepartmentRepository) ListActiveByDepartmentIDs(ctx context.Context, departmentIDs []uuid.UUID) ([]userDepartmentRow, error) { + db := DBFromContext(ctx, r.db) + rows := make([]userDepartmentRow, 0) + if len(departmentIDs) == 0 { + return rows, nil + } + err := db.WithContext(ctx). + Table("user_department"). + Select("user_id, department_id"). + Where("department_id IN ? AND removed_at IS NULL", departmentIDs). + Find(&rows).Error + return rows, err +} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index 02e033e..5f711be 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -10,22 +10,22 @@ import ( ) type UserRepositoryImpl struct { - db *gorm.DB + b *gorm.DB } func NewUserRepository(db *gorm.DB) *UserRepositoryImpl { return &UserRepositoryImpl{ - db: db, + b: db, } } func (r *UserRepositoryImpl) Create(ctx context.Context, user *entities.User) error { - return r.db.WithContext(ctx).Create(user).Error + return r.b.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 + err := r.b.WithContext(ctx).Preload("Profile").First(&user, "id = ?", id).Error if err != nil { return nil, err } @@ -34,7 +34,7 @@ func (r *UserRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entiti 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 + err := r.b.WithContext(ctx).Preload("Profile").Where("email = ?", email).First(&user).Error if err != nil { return nil, err } @@ -43,34 +43,35 @@ func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*ent 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 + err := r.b.WithContext(ctx).Preload("Profile").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). + err := r.b.WithContext(ctx). Where(" is_active = ?", organizationID, true). + Preload("Profile"). 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 + return r.b.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 + return r.b.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{}). + return r.b.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{}). + return r.b.WithContext(ctx).Model(&entities.User{}). Where("id = ?", id). Update("is_active", isActive).Error } @@ -79,7 +80,7 @@ func (r *UserRepositoryImpl) List(ctx context.Context, filters map[string]interf var users []*entities.User var total int64 - query := r.db.WithContext(ctx).Model(&entities.User{}) + query := r.b.WithContext(ctx).Model(&entities.User{}) for key, value := range filters { query = query.Where(key+" = ?", value) @@ -89,13 +90,13 @@ func (r *UserRepositoryImpl) List(ctx context.Context, filters map[string]interf return nil, 0, err } - err := query.Limit(limit).Offset(offset).Find(&users).Error + err := query.Limit(limit).Offset(offset).Preload("Profile").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{}) + query := r.b.WithContext(ctx).Model(&entities.User{}) for key, value := range filters { query = query.Where(key+" = ?", value) @@ -108,7 +109,7 @@ func (r *UserRepositoryImpl) Count(ctx context.Context, filters map[string]inter // RBAC helpers func (r *UserRepositoryImpl) GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) { var roles []entities.Role - err := r.db.WithContext(ctx). + err := r.b.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"). @@ -119,7 +120,7 @@ func (r *UserRepositoryImpl) GetRolesByUserID(ctx context.Context, userID uuid.U func (r *UserRepositoryImpl) GetPermissionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Permission, error) { var perms []entities.Permission - err := r.db.WithContext(ctx). + err := r.b.WithContext(ctx). Table("permissions as p"). Select("DISTINCT p.*"). Joins("JOIN role_permissions rp ON rp.permission_id = p.id"). @@ -129,13 +130,72 @@ func (r *UserRepositoryImpl) GetPermissionsByUserID(ctx context.Context, userID 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 +func (r *UserRepositoryImpl) GetDepartmentsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Department, error) { + var departments []entities.Department + err := r.b.WithContext(ctx). + Table("departments as d"). + Select("d.*"). + Joins("JOIN user_department ud ON ud.department_id = d.id AND ud.removed_at IS NULL"). + Where("ud.user_id = ?", userID). + Find(&departments).Error + return departments, err +} + +// GetRolesByUserIDs returns roles per user for a batch of user IDs +func (r *UserRepositoryImpl) GetRolesByUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]entities.Role, error) { + result := make(map[uuid.UUID][]entities.Role) + if len(userIDs) == 0 { + return result, nil + } + // fetch pairs user_id, role + type row struct { + UserID uuid.UUID + RoleID uuid.UUID + Name string + Code string + } + var rows []row + err := r.b.WithContext(ctx). + Table("user_role as ur"). + Select("ur.user_id, r.id as role_id, r.name, r.code"). + Joins("JOIN roles r ON r.id = ur.role_id"). + Where("ur.removed_at IS NULL AND ur.user_id IN ?", userIDs). + Scan(&rows).Error + if err != nil { + return nil, err + } + for _, rw := range rows { + role := entities.Role{ID: rw.RoleID, Name: rw.Name, Code: rw.Code} + result[rw.UserID] = append(result[rw.UserID], role) + } + return result, nil +} + +// ListWithFilters supports name search and filtering by role code +func (r *UserRepositoryImpl) ListWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]*entities.User, int64, error) { + var users []*entities.User + var total int64 + + q := r.b.WithContext(ctx).Table("users").Model(&entities.User{}) + if search != nil && *search != "" { + like := "%" + *search + "%" + q = q.Where("users.name ILIKE ?", like) + } + if isActive != nil { + q = q.Where("users.is_active = ?", *isActive) + } + if roleCode != nil && *roleCode != "" { + q = q.Joins("JOIN user_role ur ON ur.user_id = users.id AND ur.removed_at IS NULL"). + Joins("JOIN roles r ON r.id = ur.role_id"). + Where("r.code = ?", *roleCode) + } + + if err := q.Distinct("users.id").Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := q.Select("users.*").Distinct("users.id").Limit(limit).Offset(offset).Preload("Profile").Find(&users).Error; err != nil { + return nil, 0, err + } + return users, total, nil } diff --git a/internal/router/router.go b/internal/router/router.go index c2c1de0..f31e328 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -55,6 +55,7 @@ func (r *Router) Init() *gin.Engine { middleware.Recover(), middleware.HTTPStatLogger(), middleware.PopulateContext(), + middleware.CORS(), ) r.addAppRoutes(engine) @@ -76,7 +77,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { users := v1.Group("/users") users.Use(r.authMiddleware.RequireAuth()) { - users.GET("", r.authMiddleware.RequirePermissions("user.view"), r.userHandler.ListUsers) + users.GET("", r.authMiddleware.RequirePermissions("user.read"), r.userHandler.ListUsers) users.GET("/profile", r.userHandler.GetProfile) users.PUT("/profile", r.userHandler.UpdateProfile) users.PUT(":id/password", r.userHandler.ChangePassword) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index cc08589..1b692aa 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -56,10 +56,9 @@ func (s *AuthServiceImpl) Login(ctx context.Context, req *contract.LoginRequest) 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) + departments, _ := s.userProcessor.GetUserDepartments(ctx, userResponse.ID) token, expiresAt, err := s.generateToken(userResponse, roles, permCodes) if err != nil { @@ -72,7 +71,7 @@ func (s *AuthServiceImpl) Login(ctx context.Context, req *contract.LoginRequest) User: *userResponse, Roles: roles, Permissions: permCodes, - Positions: positions, + Departments: departments, }, nil } @@ -116,14 +115,14 @@ func (s *AuthServiceImpl) RefreshToken(ctx context.Context, tokenString string) return nil, fmt.Errorf("failed to generate token: %w", err) } - positions, _ := s.userProcessor.GetUserPositions(ctx, userResponse.ID) + departments, _ := s.userProcessor.GetUserDepartments(ctx, userResponse.ID) return &contract.LoginResponse{ Token: newToken, ExpiresAt: expiresAt, User: *userResponse, Roles: roles, Permissions: permCodes, - Positions: positions, + Departments: departments, }, nil } diff --git a/internal/service/user_processor.go b/internal/service/user_processor.go index 57a554d..c3d2944 100644 --- a/internal/service/user_processor.go +++ b/internal/service/user_processor.go @@ -14,14 +14,16 @@ type UserProcessor interface { 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) + GetUserDepartments(ctx context.Context, userID uuid.UUID) ([]contract.DepartmentResponse, error) GetUserProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) UpdateUserProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) + + // New optimized listing + ListUsersWithFilters(ctx context.Context, req *contract.ListUsersRequest) ([]contract.UserResponse, int, error) } diff --git a/internal/service/user_service.go b/internal/service/user_service.go index 2c7aeb1..80d5532 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -56,7 +56,7 @@ func (s *UserServiceImpl) ListUsers(ctx context.Context, req *contract.ListUsers limit = 10 } - userResponses, totalCount, err := s.userProcessor.ListUsers(ctx, page, limit) + userResponses, totalCount, err := s.userProcessor.ListUsersWithFilters(ctx, req) if err != nil { return nil, err } @@ -72,7 +72,14 @@ func (s *UserServiceImpl) ChangePassword(ctx context.Context, userID uuid.UUID, } func (s *UserServiceImpl) GetProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) { - return s.userProcessor.GetUserProfile(ctx, userID) + prof, err := s.userProcessor.GetUserProfile(ctx, userID) + if err != nil { + return nil, err + } + if roles, err := s.userProcessor.GetUserRoles(ctx, userID); err == nil { + prof.Roles = roles + } + return prof, nil } func (s *UserServiceImpl) UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) { diff --git a/internal/transformer/common_transformer.go b/internal/transformer/common_transformer.go index 61ff598..0359b57 100644 --- a/internal/transformer/common_transformer.go +++ b/internal/transformer/common_transformer.go @@ -78,13 +78,13 @@ func RolesToContract(roles []entities.Role) []contract.RoleResponse { return res } -func PositionsToContract(positions []entities.Position) []contract.PositionResponse { +func DepartmentsToContract(positions []entities.Department) []contract.DepartmentResponse { if positions == nil { return nil } - res := make([]contract.PositionResponse, 0, len(positions)) + res := make([]contract.DepartmentResponse, 0, len(positions)) for _, p := range positions { - res = append(res, contract.PositionResponse{ID: p.ID, Name: p.Name, Code: p.Code, Path: p.Path}) + res = append(res, contract.DepartmentResponse{ID: p.ID, Name: p.Name, Code: p.Code, Path: p.Path}) } return res } diff --git a/internal/transformer/user_transformer.go b/internal/transformer/user_transformer.go index 94ede7d..71ed9f7 100644 --- a/internal/transformer/user_transformer.go +++ b/internal/transformer/user_transformer.go @@ -37,14 +37,18 @@ func EntityToContract(user *entities.User) *contract.UserResponse { if user == nil { return nil } - return &contract.UserResponse{ + resp := &contract.UserResponse{ ID: user.ID, - Name: user.Name, + Name: user.Profile.FullName, Email: user.Email, IsActive: user.IsActive, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, } + if user.Profile != nil { + resp.Profile = ProfileEntityToContract(user.Profile) + } + return resp } func EntitiesToContracts(users []*entities.User) []contract.UserResponse { diff --git a/migrations/000002_seed_user_data.up.sql b/migrations/000002_seed_user_data.up.sql index 600ed67..1f36b6c 100644 --- a/migrations/000002_seed_user_data.up.sql +++ b/migrations/000002_seed_user_data.up.sql @@ -1,18 +1,5 @@ BEGIN; --- ========================= --- Departments (as requested) --- ========================= --- Root org namespace is "eslogad" in ltree path -INSERT INTO departments (name, code, path) VALUES - ('RENBINMINLOG', 'renbinminlog', 'eslogad.renbinminlog'), - ('FASKON BMN', 'faskon_bmn', 'eslogad.faskon_bmn'), - ('BEKPALKES', 'bekpalkes', 'eslogad.bekpalkes') - ON CONFLICT (code) DO UPDATE - SET name = EXCLUDED.name, - path = EXCLUDED.path, - updated_at = CURRENT_TIMESTAMP; - -- ========================= -- Positions (hierarchy) -- ========================= @@ -20,80 +7,80 @@ INSERT INTO departments (name, code, path) VALUES -- - superadmin is a separate root -- - eslogad.aslog is head; waaslog_* under aslog -- - paban_* under each waaslog_*; pabandya_* under its paban_* -INSERT INTO positions (name, code, path) VALUES +INSERT INTO departments (name, code, path) VALUES -- ROOTS - ('SUPERADMIN', 'superadmin', 'superadmin'), - ('ASLOG', 'aslog', 'eslogad.aslog'), + ('SUPERADMIN', 'superadmin', 'superadmin'), + ('ASLOG', 'aslog', 'eslogad.aslog'), - -- WAASLOG under ASLOG - ('WAASLOG RENBINMINLOG', 'waaslogrenbinminlog', 'eslogad.aslog.waaslog_renbinminlog'), - ('WAASLOG FASKON BMN', 'waaslogfaskonbmn', 'eslogad.aslog.waaslog_faskon_bmn'), - ('WAASLOG BEKPALKES', 'waaslogbekpalkes', 'eslogad.aslog.waaslog_bekpalkes'), + -- WAASLOG under ASLOG + ('WAASLOG RENBINMINLOG', 'waaslogrenbinminlog', 'eslogad.aslog.waaslog_renbinminlog'), + ('WAASLOG FASKON BMN', 'waaslogfaskonbmn', 'eslogad.aslog.waaslog_faskon_bmn'), + ('WAASLOG BEKPALKES', 'waaslogbekpalkes', 'eslogad.aslog.waaslog_bekpalkes'), - -- Other posts directly under ASLOG - ('KADISADAAD', 'kadisadaad', 'eslogad.aslog.kadisadaad'), - ('KATUUD', 'katuud', 'eslogad.aslog.katuud'), - ('SPRI', 'spri', 'eslogad.aslog.spri'), + -- Other posts directly under ASLOG + ('KADISADAAD', 'kadisadaad', 'eslogad.aslog.kadisadaad'), + ('KATUUD', 'katuud', 'eslogad.aslog.katuud'), + ('SPRI', 'spri', 'eslogad.aslog.spri'), - -- PABAN under WAASLOG RENBINMINLOG - ('PABAN I/REN', 'paban-I-ren', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren'), - ('PABAN II/BINMINLOG', 'paban-II-binminlog', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog'), + -- PABAN under WAASLOG RENBINMINLOG + ('PABAN I/REN', 'paban-I-ren', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren'), + ('PABAN II/BINMINLOG', 'paban-II-binminlog', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog'), - -- PABAN under WAASLOG FASKON BMN - ('PABAN III/FASKON', 'paban-III-faskon', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon'), - ('PABAN IV/BMN', 'paban-iv-bmn', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn'), + -- PABAN under WAASLOG FASKON BMN + ('PABAN III/FASKON', 'paban-III-faskon', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon'), + ('PABAN IV/BMN', 'paban-iv-bmn', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn'), - -- PABAN under WAASLOG BEKPALKES - ('PABAN V/BEK', 'paban-v-bek', 'eslogad.aslog.waaslog_bekpalkes.paban_V_bek'), - ('PABAN VI/ALPAL', 'paban-vi-alpal', 'eslogad.aslog.waaslog_bekpalkes.paban_VI_alpal'), - ('PABAN VII/KES', 'paban-vii-kes', 'eslogad.aslog.waaslog_bekpalkes.paban_VII_kes'), + -- PABAN under WAASLOG BEKPALKES + ('PABAN V/BEK', 'paban-v-bek', 'eslogad.aslog.waaslog_bekpalkes.paban_V_bek'), + ('PABAN VI/ALPAL', 'paban-vi-alpal', 'eslogad.aslog.waaslog_bekpalkes.paban_VI_alpal'), + ('PABAN VII/KES', 'paban-vii-kes', 'eslogad.aslog.waaslog_bekpalkes.paban_VII_kes'), - -- PABANDYA under PABAN I/REN - ('PABANDYA 1 / RENPROGGAR', 'pabandya-1-renproggar', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren.pabandya_1_renproggar'), - ('PABANDYA 2 / DALWASGAR', 'pabandya-2-dalwasgar', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren.pabandya_2_dalwasgar'), - ('PABANDYA 3 / ANEVDATA', 'pabandya-3-anevdata', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren.pabandya_3_anevdata'), + -- PABANDYA under PABAN I/REN + ('PABANDYA 1 / RENPROGGAR', 'pabandya-1-renproggar', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren.pabandya_1_renproggar'), + ('PABANDYA 2 / DALWASGAR', 'pabandya-2-dalwasgar', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren.pabandya_2_dalwasgar'), + ('PABANDYA 3 / ANEVDATA', 'pabandya-3-anevdata', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren.pabandya_3_anevdata'), - -- PABANDYA under PABAN II/BINMINLOG - ('PABANDYA 1 / MINLOG', 'pabandya-1-minlog', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog.pabandya_1_minlog'), - ('PABANDYA 2 / HIBAHKOD', 'pabandya-2-hibahkod', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog.pabandya_2_hibahkod'), - ('PABANDYA 3 / PUSMAT', 'pabandya-3-pusmat', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog.pabandya_3_pusmat'), + -- PABANDYA under PABAN II/BINMINLOG + ('PABANDYA 1 / MINLOG', 'pabandya-1-minlog', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog.pabandya_1_minlog'), + ('PABANDYA 2 / HIBAHKOD', 'pabandya-2-hibahkod', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog.pabandya_2_hibahkod'), + ('PABANDYA 3 / PUSMAT', 'pabandya-3-pusmat', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog.pabandya_3_pusmat'), - -- PABANDYA under PABAN IV/BMN - ('PABANDYA 1 / TANAH', 'pabandya-1-tanah', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_1_tanah'), - ('PABANDYA 2 / PANGKALAN KONSTRUKSI','pabandya-2-pangkalankonstruksi','eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_2_pangkalan_konstruksi'), - ('PABANDYA 3 / FASMATZI', 'pabandya-3-fasmatzi', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_3_fasmatzi'), + -- PABANDYA under PABAN IV/BMN + ('PABANDYA 1 / TANAH', 'pabandya-1-tanah', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_1_tanah'), + ('PABANDYA 2 / PANGKALAN KONSTRUKSI','pabandya-2-pangkalankonstruksi','eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_2_pangkalan_konstruksi'), + ('PABANDYA 3 / FASMATZI', 'pabandya-3-fasmatzi', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_3_fasmatzi'), - -- PABANDYA under PABAN IV/BMN (AKUN group) - ('PABANDYA 1 / AKUN BB', 'pabandya-1-akunbb', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_1_akun_bb'), - ('PABANDYA 2 / AKUN BTB', 'pabandya-2-akunbtb', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_2_akun_btb'), - ('PABANDYA 3 / SISFO BMN DAN UAKPB-KP','pabandya-3-sisfo-bmn-uakpbkp','eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_3_sisfo_bmn_uakpb_kp'), + -- PABANDYA under PABAN IV/BMN (AKUN group) + ('PABANDYA 1 / AKUN BB', 'pabandya-1-akunbb', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_1_akun_bb'), + ('PABANDYA 2 / AKUN BTB', 'pabandya-2-akunbtb', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_2_akun_btb'), + ('PABANDYA 3 / SISFO BMN DAN UAKPB-KP','pabandya-3-sisfo-bmn-uakpbkp','eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_3_sisfo_bmn_uakpb_kp'), - -- PABANDYA under PABAN III/FASKON - ('PABANDYA 1 / JATOPTIKMU', 'pabandya-1-jatoptikmu', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_1_jatoptikmu'), - ('PABANDYA 2 / RANTEKMEK', 'pabandya-2-rantekmek', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_2_rantekmek'), - ('PABANDYA 3 / ALHUBTOPPALSUS', 'pabandya-3-alhubtoppalsus', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_3_alhubtoppalsus'), - ('PABANDYA 4 / PESUD', 'pabandya-4-pesud', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_4_pesud'), + -- PABANDYA under PABAN III/FASKON + ('PABANDYA 1 / JATOPTIKMU', 'pabandya-1-jatoptikmu', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_1_jatoptikmu'), + ('PABANDYA 2 / RANTEKMEK', 'pabandya-2-rantekmek', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_2_rantekmek'), + ('PABANDYA 3 / ALHUBTOPPALSUS', 'pabandya-3-alhubtoppalsus', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_3_alhubtoppalsus'), + ('PABANDYA 4 / PESUD', 'pabandya-4-pesud', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_4_pesud'), - -- PABANDYA under PABAN VII/KES - ('PABANDYA 1 / BEKKES', 'pabandya-1-bekkes', 'eslogad.aslog.waaslog_bekpalkes.paban_VII_kes.pabandya_1_bekkes'), - ('PABANDYA 2 / ALKES', 'pabandya-2-alkes', 'eslogad.aslog.waaslog_bekpalkes.paban_VII_kes.pabandya_2_alkes') - ON CONFLICT (code) DO UPDATE - SET name = EXCLUDED.name, - path = EXCLUDED.path, - updated_at = CURRENT_TIMESTAMP; + -- PABANDYA under PABAN VII/KES + ('PABANDYA 1 / BEKKES', 'pabandya-1-bekkes', 'eslogad.aslog.waaslog_bekpalkes.paban_VII_kes.pabandya_1_bekkes'), + ('PABANDYA 2 / ALKES', 'pabandya-2-alkes', 'eslogad.aslog.waaslog_bekpalkes.paban_VII_kes.pabandya_2_alkes') +ON CONFLICT (code) DO UPDATE + SET name = EXCLUDED.name, + path = EXCLUDED.path, + updated_at = CURRENT_TIMESTAMP; -- ========================= -- SUPERADMIN role (minimal) -- ========================= INSERT INTO roles (name, code, description) VALUES - ('SUPERADMIN', 'superadmin', 'Full system access and management'), - ('ADMIN', 'admin', 'Manage users, letters, and settings within their department'), - ('HEAD', 'head', 'Approve outgoing letters and manage dispositions in their department'), - ('STAFF', 'staff', 'Create letters, process assigned dispositions') - ON CONFLICT (code) DO UPDATE - SET name = EXCLUDED.name, - description = EXCLUDED.description, - updated_at = CURRENT_TIMESTAMP; + ('SUPERADMIN', 'superadmin', 'Full system access and management'), + ('ADMIN', 'admin', 'Manage users, letters, and settings within their department'), + ('HEAD', 'head', 'Approve outgoing letters and manage dispositions in their department'), + ('STAFF', 'staff', 'Create letters, process assigned dispositions') +ON CONFLICT (code) DO UPDATE + SET name = EXCLUDED.name, + description = EXCLUDED.description, + updated_at = CURRENT_TIMESTAMP; -- ========================= -- Users (seed 1 superadmin) diff --git a/migrations/000011_settings.down.sql b/migrations/000011_settings.down.sql new file mode 100644 index 0000000..fb1ec1a --- /dev/null +++ b/migrations/000011_settings.down.sql @@ -0,0 +1,4 @@ +BEGIN; +DROP TRIGGER IF EXISTS trg_app_settings_updated_at ON app_settings; +DROP TABLE IF EXISTS app_settings; +COMMIT; \ No newline at end of file diff --git a/migrations/000011_settings.up.sql b/migrations/000011_settings.up.sql new file mode 100644 index 0000000..91eee5b --- /dev/null +++ b/migrations/000011_settings.up.sql @@ -0,0 +1,21 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TRIGGER trg_app_settings_updated_at + BEFORE UPDATE ON app_settings + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +INSERT INTO app_settings(key, value) +VALUES + ('INCOMING_LETTER_PREFIX', '{"value": "ESLI"}'::jsonb), + ('INCOMING_LETTER_SEQUENCE', '{"value": 0}'::jsonb), + ('INCOMING_LETTER_RECIPIENTS', '{"department_codes": ["aslog"]}'::jsonb) +ON CONFLICT (key) DO NOTHING; + +COMMIT; \ No newline at end of file