first commit

This commit is contained in:
meninjar
2026-04-14 01:23:34 +00:00
commit edfaa886ff
443 changed files with 1245931 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
package pages
// RolPagesRequest represents the request payload for RolPages
type RolPagesRequest struct {
Name string `json:"name" validate:"required,max=20"`
Icon string `json:"icon" validate:"max=100"`
URL string `json:"url" validate:"required"`
Level int16 `json:"level" validate:"required,min=0"`
Sort int16 `json:"sort" validate:"required,min=0"`
Parent *int64 `json:"parent"`
Active bool `json:"active"`
}
// RolPagesResponse represents the response payload for RolPages
type RolPagesResponse struct {
Id int64 `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
URL string `json:"url"`
Level int16 `json:"level"`
Sort int16 `json:"sort"`
Parent *int64 `json:"parent,omitempty"`
Active bool `json:"active"`
Children []*RolPagesResponse `json:"children,omitempty"`
}
// RolPagesTreeResponse represents hierarchical menu structure
type RolPagesTreeResponse struct {
Id int64 `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
URL string `json:"url"`
Level int16 `json:"level"`
Sort int16 `json:"sort"`
Active bool `json:"active"`
Children []*RolPagesTreeResponse `json:"children,omitempty"`
}
+28
View File
@@ -0,0 +1,28 @@
package pages
import (
"time"
)
// RolPages entity represents the rol_pages table in the database
type RolPages struct {
Id int64 `json:"id" db:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" db:"name"`
Icon string `json:"icon" db:"icon"`
URL string `json:"url" db:"url"`
Level int16 `json:"level" db:"level"`
Sort int16 `json:"sort" db:"sort"`
Parent *int64 `json:"parent" db:"parent"`
Active bool `json:"active" db:"active"`
CreatedAt *time.Time `json:"created_at" db:"created_at"`
UpdatedAt *time.Time `json:"updated_at" db:"updated_at"`
// Relasi untuk hirarki menu
Children []RolPages `json:"children" gorm:"-"`
ParentRef *RolPages `json:"parent_ref,omitempty" gorm:"-" db:"-"`
}
// TableName specifies the table name for RolPages
func (RolPages) TableName() string {
return "role_access.rol_pages"
}
+89
View File
@@ -0,0 +1,89 @@
package pages
// mapRequestToEntity converts RolPagesRequest to *RolPages entity
func mapRequestToEntity(req RolPagesRequest) *RolPages {
return &RolPages{
Name: req.Name,
Icon: req.Icon,
URL: req.URL,
Level: req.Level,
Sort: req.Sort,
Parent: req.Parent,
Active: req.Active,
}
}
// mapEntityToResponse converts *RolPages entity to *RolPagesResponse DTO
func mapEntityToResponse(e *RolPages) *RolPagesResponse {
return &RolPagesResponse{
Id: e.Id,
Name: e.Name,
Icon: e.Icon,
URL: e.URL,
Level: e.Level,
Sort: e.Sort,
Parent: e.Parent,
Active: e.Active,
}
}
// mapEntityToTreeResponse converts *RolPages entity to *RolPagesTreeResponse DTO
func mapEntityToTreeResponse(e *RolPages) *RolPagesTreeResponse {
return &RolPagesTreeResponse{
Id: e.Id,
Name: e.Name,
Icon: e.Icon,
URL: e.URL,
Level: e.Level,
Sort: e.Sort,
Active: e.Active,
}
}
// buildTreeHierarchy membangun struktur hirarki dari flat list
func buildTreeHierarchy(pages []*RolPages) []*RolPagesTreeResponse {
pageMap := make(map[int64]*RolPagesTreeResponse)
var roots []*RolPagesTreeResponse
// Convert semua pages ke TreeResponse dan buat map
for _, page := range pages {
treeResponse := mapEntityToTreeResponse(page)
pageMap[page.Id] = treeResponse
}
// Bangun relasi parent-child
for _, page := range pages {
if page.Parent == nil {
// Ini adalah root node
roots = append(roots, pageMap[page.Id])
} else {
// Ini adalah child node
if parent, exists := pageMap[*page.Parent]; exists {
parent.Children = append(parent.Children, pageMap[page.Id])
}
}
}
// Sort children berdasarkan sort order
for _, page := range pageMap {
if len(page.Children) > 0 {
sortChildren(page.Children)
}
}
// Sort root nodes berdasarkan sort order
sortChildren(roots)
return roots
}
// sortChildren mengurutkan children berdasarkan field Sort
func sortChildren(children []*RolPagesTreeResponse) {
for i := 0; i < len(children); i++ {
for j := i + 1; j < len(children); j++ {
if children[i].Sort > children[j].Sort {
children[i], children[j] = children[j], children[i]
}
}
}
}
+227
View File
@@ -0,0 +1,227 @@
package pages
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"service/internal/infrastructure/database"
"service/pkg/utils/query"
"github.com/jmoiron/sqlx"
"gorm.io/gorm"
)
// CommandRepository menangani operasi Write (Create, Update, Delete) ke Master DB
type CommandRepository interface {
Create(ctx context.Context, entity *RolPages) error
Update(ctx context.Context, entity *RolPages) error
Delete(ctx context.Context, id int64) error
}
// QueryRepository menangani operasi Read (Find, Search) ke Read-Replica DB
type QueryRepository interface {
FindByID(ctx context.Context, id int64) (*RolPages, error)
GetChildren(ctx context.Context, parentID int64) ([]RolPages, error)
Search(ctx context.Context, filters map[string]interface{}, sorts []query.SortField, limit, offset int) ([]RolPages, int64, error)
}
type repository struct {
dbManager database.Service
dbName string
qb query.QueryBuilder
dbType query.DBType
allowedColumnsMap map[string]bool
}
// Konstruktor Baru CQRS
func NewCommandRepository(dbManager database.Service, dbName string) CommandRepository {
return NewRepository(dbManager, dbName)
}
func NewQueryRepository(dbManager database.Service, dbName string) QueryRepository {
return NewRepository(dbManager, dbName)
}
func NewRepository(dbManager database.Service, dbName string) *repository {
dbType := query.DBTypePostgreSQL
allowedColumns := []string{
"id",
"name",
"icon",
"url",
"level",
"sort",
"parent",
"active",
"created_at",
"updated_at",
}
qb := query.NewSQLQueryBuilder(dbType).
SetSecurityOptions(true, 1000).
SetQueryLogging(true).
SetQueryTimeout(30).
SetAllowedColumns(allowedColumns)
return &repository{dbManager: dbManager, dbName: dbName, qb: qb, dbType: dbType, allowedColumnsMap: createAllowedColumnsMap(allowedColumns)}
}
// Helper function untuk membuat map dari allowed columns
func createAllowedColumnsMap(columns []string) map[string]bool {
result := make(map[string]bool)
for _, col := range columns {
result[col] = true
}
return result
}
// isColumnAllowed memvalidasi apakah kolom diizinkan untuk digunakan
func (r *repository) isColumnAllowed(column string) bool {
return r.allowedColumnsMap[column]
}
// Helper mengambil koneksi Master dari DB Manager
func (r *repository) getWriteGormDB() (*gorm.DB, error) {
return r.dbManager.GetGormDB(r.dbName)
}
// Helper mengambil koneksi Read-Replica dari DB Manager
func (r *repository) getReadSQLXDB() (*sqlx.DB, error) {
sqlDB, err := r.dbManager.GetReadDB(r.dbName)
if err != nil {
return nil, fmt.Errorf("failed to get read db: %w", err)
}
return sqlx.NewDb(sqlDB, "pgx"), nil
}
// --- Implementasi Write Model ---
func (r *repository) Create(ctx context.Context, entity *RolPages) error {
db, err := r.getWriteGormDB()
if err != nil {
return err
}
return db.WithContext(ctx).Table(entity.TableName()).Create(entity).Error
}
func (r *repository) Update(ctx context.Context, entity *RolPages) error {
db, err := r.getWriteGormDB()
if err != nil {
return err
}
return db.WithContext(ctx).Table(entity.TableName()).Save(entity).Error
}
func (r *repository) Delete(ctx context.Context, id int64) error {
db, err := r.getWriteGormDB()
if err != nil {
return err
}
return db.WithContext(ctx).Delete(&RolPages{}, id).Error
}
// --- Implementasi Read Model ---
func (r *repository) FindByID(ctx context.Context, id int64) (*RolPages, error) {
sqlxDB, err := r.getReadSQLXDB()
if err != nil {
return nil, err
}
q := query.DynamicQuery{
From: "role_access.rol_pages",
Filters: []query.FilterGroup{{
Filters: []query.DynamicFilter{query.CreateEqualFilter("id", id)},
}},
Limit: 1,
}
var entity RolPages
if err := r.qb.ExecuteQueryRow(ctx, sqlxDB, q, &entity); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("failed to fetch RolPages by ID: %w", err)
}
return &entity, nil
}
func (r *repository) GetChildren(ctx context.Context, parentID int64) ([]RolPages, error) {
sqlxDB, err := r.getReadSQLXDB()
if err != nil {
return nil, err
}
q := query.DynamicQuery{
From: "role_access.rol_pages",
Filters: []query.FilterGroup{{
Filters: []query.DynamicFilter{query.CreateEqualFilter("parent", parentID)},
}},
Sort: []query.SortField{query.CreateAscSort("sort")},
}
var entities []RolPages
if err := r.qb.ExecuteQuery(ctx, sqlxDB, q, &entities); err != nil {
return nil, fmt.Errorf("failed to fetch RolPages children: %w", err)
}
return entities, nil
}
func (r *repository) Search(ctx context.Context, filters map[string]interface{}, sorts []query.SortField, limit, offset int) ([]RolPages, int64, error) {
sqlxDB, err := r.getReadSQLXDB()
if err != nil {
return nil, 0, err
}
var dynamicFilters []query.DynamicFilter
for k, v := range filters {
// Normalisasi nama kolom menjadi lowercase agar sesuai dengan allowedColumns
colName := strings.ToLower(k)
if !r.isColumnAllowed(colName) {
continue
}
switch val := v.(type) {
case string:
if val != "" {
// Gunakan OpILike hanya untuk kolom yang bertipe teks (string)
if colName == "name" || colName == "icon" || colName == "url" {
dynamicFilters = append(dynamicFilters, query.CreateFilter(colName, query.OpILike, "%"+val+"%"))
} else {
if boolVal, err := strconv.ParseBool(val); err == nil && (val == "true" || val == "false" || val == "1" || val == "0") {
dynamicFilters = append(dynamicFilters, query.CreateEqualFilter(colName, boolVal))
} else if intVal, err := strconv.Atoi(val); err == nil {
dynamicFilters = append(dynamicFilters, query.CreateEqualFilter(colName, intVal))
} else {
dynamicFilters = append(dynamicFilters, query.CreateEqualFilter(colName, val))
}
}
}
default:
dynamicFilters = append(dynamicFilters, query.CreateEqualFilter(colName, val))
}
}
if len(sorts) == 0 {
sorts = []query.SortField{query.CreateAscSort("id")}
}
q := query.DynamicQuery{
From: "role_access.rol_pages",
Fields: []query.SelectField{{Expression: "*"}},
Filters: []query.FilterGroup{{Filters: dynamicFilters}},
Limit: limit,
Offset: offset,
Sort: sorts,
}
var entities []RolPages
if err := r.qb.ExecuteQuery(ctx, sqlxDB, q, &entities); err != nil {
return nil, 0, fmt.Errorf("failed to execute search query: %w", err)
}
count, err := r.qb.ExecuteCount(ctx, sqlxDB, q)
if err != nil {
return nil, 0, fmt.Errorf("failed to execute search count query: %w", err)
}
return entities, count, nil
}
+308
View File
@@ -0,0 +1,308 @@
package pages
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"service/internal/infrastructure/cache"
"service/pkg/errors"
"service/pkg/logger"
"service/pkg/utils/query"
"strings"
"time"
"gorm.io/gorm"
)
type Service interface {
GetList(ctx context.Context, limit, offset int, sorts []string, activeFilter *bool) (map[string]interface{}, error)
GetDetail(ctx context.Context, id int64) (*RolPagesResponse, error)
Search(ctx context.Context, filters map[string]interface{}, sorts []string, limit, offset int) (map[string]interface{}, error)
Create(ctx context.Context, req RolPagesRequest) (*RolPagesResponse, error)
Update(ctx context.Context, id int64, req RolPagesRequest) (*RolPagesResponse, error)
Delete(ctx context.Context, id int64) error
GetTree(ctx context.Context, activeFilter *bool) ([]*RolPagesTreeResponse, error)
GetTreeByLevel(ctx context.Context, level int16, activeFilter *bool) ([]*RolPagesTreeResponse, error)
}
type service struct {
cmdRepo CommandRepository
queryRepo QueryRepository
cache *cache.Manager
}
func NewService(cmdRepo CommandRepository, queryRepo QueryRepository, cacheManager *cache.Manager) Service {
return &service{cmdRepo: cmdRepo, queryRepo: queryRepo, cache: cacheManager}
}
func (s *service) GetList(ctx context.Context, limit, offset int, sorts []string, activeFilter *bool) (map[string]interface{}, error) {
filters := map[string]interface{}{}
if activeFilter != nil {
filters["active"] = *activeFilter
}
return s.Search(ctx, filters, sorts, limit, offset)
}
func (s *service) GetDetail(ctx context.Context, id int64) (*RolPagesResponse, error) {
if id <= 0 {
return nil, errors.NewValidationError().Message("Invalid ID").Metadata("id", id).Build()
}
entity, err := s.queryRepo.FindByID(ctx, id)
if err != nil {
return nil, errors.InternalError().Message("Failed to retrieve RolPages detail").Cause(err).Build()
}
if entity == nil {
return nil, errors.NotFoundError().Message("RolPages not found").Metadata("id", id).Build()
}
response := mapEntityToResponse(entity)
// Ambil children jika ada
children, err := s.queryRepo.GetChildren(ctx, id)
if err == nil && len(children) > 0 {
response.Children = make([]*RolPagesResponse, len(children))
for i := range children {
response.Children[i] = mapEntityToResponse(&children[i])
}
}
return response, nil
}
func (s *service) Search(ctx context.Context, filters map[string]interface{}, sorts []string, limit, offset int) (map[string]interface{}, error) {
if limit == 0 {
limit = 10
} else if limit < -1 || limit > 100 {
limit = 10
}
if offset < 0 {
offset = 0
}
// --- 1. Implementasi Caching (Check) ---
filterBytes, _ := json.Marshal(filters)
sortBytes, _ := json.Marshal(sorts)
hashInput := fmt.Sprintf("%s|%s|%d|%d", string(filterBytes), string(sortBytes), limit, offset)
hash := sha256.Sum256([]byte(hashInput))
cacheKey := fmt.Sprintf("role_pages_search_v2:%s", hex.EncodeToString(hash[:]))
if s.cache != nil {
var strData string
if err := s.cache.Get(ctx, cacheKey, &strData); err == nil && strData != "" {
var cachedData struct {
Data []*RolPagesResponse `json:"data"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
if err := json.Unmarshal([]byte(strData), &cachedData); err == nil {
logger.Default().Debug("Cache hit for Role Pages Search", logger.String("key", cacheKey))
return map[string]interface{}{
"data": cachedData.Data,
"total": cachedData.Total,
"limit": cachedData.Limit,
"offset": cachedData.Offset,
}, nil
}
}
}
// Konversi sorts string ke SortField
var sortFields []query.SortField
for _, sort := range sorts {
if sort == "" {
continue
}
// Default ASC, tandai dengan - untuk DESC
order := "ASC"
column := sort
if strings.HasPrefix(sort, "-") {
order = "DESC"
column = strings.TrimPrefix(sort, "-")
} else if strings.HasPrefix(sort, "+") {
column = strings.TrimPrefix(sort, "+")
}
// Validasi kolom yang diizinkan (lowercase untuk mapping)
allowedColumns := map[string]bool{
"id": true,
"name": true,
"level": true,
"sort": true,
"active": true,
"parent": true,
"icon": true,
"url": true,
"created_at": true,
"updated_at": true,
}
if allowedColumns[column] {
sortFields = append(sortFields, query.SortField{
Column: column,
Order: order,
})
}
}
// Jika tidak ada sort yang valid, gunakan default
if len(sortFields) == 0 {
sortFields = []query.SortField{query.CreateAscSort("id")}
}
entities, total, err := s.queryRepo.Search(ctx, filters, sortFields, limit, offset)
if err != nil {
return nil, errors.InternalError().Message("Failed to search RolPagess").Cause(err).Build()
}
responses := make([]*RolPagesResponse, len(entities))
for i := range entities {
responses[i] = mapEntityToResponse(&entities[i])
}
responseMap := map[string]interface{}{
"data": responses, "total": total, "limit": limit, "offset": offset,
}
// --- 2. Implementasi Caching (Set) ---
if s.cache != nil {
if bytes, err := json.Marshal(responseMap); err == nil {
_ = s.cache.Set(ctx, cacheKey, string(bytes), 5*time.Minute)
}
}
return responseMap, nil
}
func (s *service) Create(ctx context.Context, req RolPagesRequest) (*RolPagesResponse, error) {
// TODO: Add validation here if needed
entity := mapRequestToEntity(req)
if err := s.cmdRepo.Create(ctx, entity); err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, errors.AlreadyExistsError().Message("RolPages with this identifier already exists").Cause(err).Build()
}
return nil, errors.InternalError().Message("Failed to create RolPages").Cause(err).Build()
}
if s.cache != nil {
_ = s.cache.Delete(ctx, "role_pages_search_v2:*")
}
return mapEntityToResponse(entity), nil
}
func (s *service) GetTree(ctx context.Context, activeFilter *bool) ([]*RolPagesTreeResponse, error) {
filters := map[string]interface{}{}
if activeFilter != nil {
filters["active"] = *activeFilter
}
pages, _, err := s.queryRepo.Search(ctx, filters, []query.SortField{query.CreateAscSort("sort"), query.CreateAscSort("id")}, -1, 0)
if err != nil {
return nil, errors.InternalError().Message("Failed to retrieve pages").Cause(err).Build()
}
// Konversi []RolPages ke []*RolPages
pagePointers := make([]*RolPages, len(pages))
for i := range pages {
pagePointers[i] = &pages[i]
}
return buildTreeHierarchy(pagePointers), nil
}
func (s *service) GetTreeByLevel(ctx context.Context, level int16, activeFilter *bool) ([]*RolPagesTreeResponse, error) {
filters := map[string]interface{}{"level": level}
if activeFilter != nil {
filters["active"] = *activeFilter
}
pages, _, err := s.queryRepo.Search(ctx, filters, []query.SortField{query.CreateAscSort("sort"), query.CreateAscSort("id")}, -1, 0)
if err != nil {
return nil, errors.InternalError().Message("Failed to retrieve pages by level").Cause(err).Build()
}
// Konversi []RolPages ke []*RolPages
pagePointers := make([]*RolPages, len(pages))
for i := range pages {
pagePointers[i] = &pages[i]
}
return buildTreeHierarchy(pagePointers), nil
}
func (s *service) Update(ctx context.Context, id int64, req RolPagesRequest) (*RolPagesResponse, error) {
if id <= 0 {
return nil, errors.NewValidationError().Message("Invalid ID").Metadata("id", id).Build()
}
// Cek apakah record ada
existing, err := s.queryRepo.FindByID(ctx, id)
if err != nil {
return nil, errors.InternalError().Message("Failed to retrieve RolPages").Cause(err).Build()
}
if existing == nil {
return nil, errors.NotFoundError().Message("RolPages not found").Metadata("id", id).Build()
}
// Validasi parent jika berubah dan tidak boleh circular reference
if req.Parent != nil && *req.Parent != id {
if *req.Parent == id {
return nil, errors.NewValidationError().Message("Cannot set self as parent").Build()
}
parent, err := s.queryRepo.FindByID(ctx, *req.Parent)
if err != nil {
return nil, errors.InternalError().Message("Failed to validate parent").Cause(err).Build()
}
if parent == nil {
return nil, errors.NotFoundError().Message("Parent page not found").Metadata("parent_id", *req.Parent).Build()
}
}
// Update entity
existing.Name = req.Name
existing.Icon = req.Icon
existing.URL = req.URL
existing.Level = req.Level
existing.Sort = req.Sort
existing.Parent = req.Parent
existing.Active = req.Active
if err := s.cmdRepo.Update(ctx, existing); err != nil {
return nil, errors.InternalError().Message("Failed to update RolPages").Cause(err).Build()
}
if s.cache != nil {
_ = s.cache.Delete(ctx, "role_pages_search_v2:*")
}
return mapEntityToResponse(existing), nil
}
func (s *service) Delete(ctx context.Context, id int64) error {
if id <= 0 {
return errors.NewValidationError().Message("Invalid ID").Metadata("id", id).Build()
}
// Cek apakah memiliki children
children, err := s.queryRepo.GetChildren(ctx, id)
if err != nil {
return errors.InternalError().Message("Failed to check children").Cause(err).Build()
}
if len(children) > 0 {
return errors.NewValidationError().Message("Cannot delete page with children").Metadata("children_count", len(children)).Build()
}
if err := s.cmdRepo.Delete(ctx, id); err != nil {
return errors.InternalError().Message("Failed to delete RolPages").Cause(err).Build()
}
if s.cache != nil {
_ = s.cache.Delete(ctx, "role_pages_search_v2:*")
}
return nil
}