This commit is contained in:
Aditya Siregar 2025-08-09 23:44:03 +07:00
parent 61d6eed373
commit de60983e4e
24 changed files with 493 additions and 153 deletions

View File

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

View File

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

View File

@ -44,7 +44,7 @@ type LoginResponse struct {
User UserResponse `json:"user"`
Roles []RoleResponse `json:"roles"`
Permissions []string `json:"permissions"`
Positions []PositionResponse `json:"positions"`
Departments []DepartmentResponse `json:"departments"`
}
type UserResponse struct {
@ -54,6 +54,8 @@ type UserResponse struct {
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 {

View File

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

View File

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

View File

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

View File

@ -47,6 +47,7 @@ type User struct {
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,7 +7,7 @@ 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'),

View File

@ -0,0 +1,4 @@
BEGIN;
DROP TRIGGER IF EXISTS trg_app_settings_updated_at ON app_settings;
DROP TABLE IF EXISTS app_settings;
COMMIT;

View File

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