diff --git a/cmd/api/main.go b/cmd/api/main.go index a7c278d7..76de7729 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -14,8 +14,8 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/database" "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" "gitlab.com/mbugroup/lti-api.git/internal/route" - "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/gofiber/fiber/v2" diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index a831c25b..1b670c14 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -7,8 +7,8 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" ) diff --git a/internal/middleware/role_scope.go b/internal/middleware/role_scope.go new file mode 100644 index 00000000..f00e12d9 --- /dev/null +++ b/internal/middleware/role_scope.go @@ -0,0 +1,280 @@ +package middleware + +import ( + "errors" + "strings" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +type ScopeFilter struct { + IDs []uint + Restrict bool +} + +type roleScope struct { + allArea bool + allLocation bool + areaIDs []uint + locationIDs []uint + hasAnyScopes bool +} + +func ResolveAreaScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) { + scope, err := collectRoleScope(c) + if err != nil || !scope.hasAnyScopes { + return ScopeFilter{}, err + } + + if scope.allArea || scope.allLocation { + return ScopeFilter{}, nil + } + + allowed := uniqueUint(scope.areaIDs) + if len(scope.locationIDs) > 0 { + derived, err := areaIDsByLocationIDs(db, scope.locationIDs) + if err != nil { + return ScopeFilter{}, err + } + allowed = uniqueUint(append(allowed, derived...)) + } + + if len(allowed) == 0 { + return ScopeFilter{Restrict: true}, nil + } + return ScopeFilter{IDs: allowed, Restrict: true}, nil +} + +func ResolveLocationScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) { + scope, err := collectRoleScope(c) + if err != nil || !scope.hasAnyScopes { + return ScopeFilter{}, err + } + + if scope.allLocation || scope.allArea { + return ScopeFilter{}, nil + } + + areaIDs := uniqueUint(scope.areaIDs) + locationIDs := uniqueUint(scope.locationIDs) + + switch { + case len(locationIDs) > 0 && len(areaIDs) > 0: + filtered, err := filterLocationIDsByAreaIDs(db, locationIDs, areaIDs) + if err != nil { + return ScopeFilter{}, err + } + locationIDs = filtered + case len(locationIDs) == 0 && len(areaIDs) > 0: + derived, err := locationIDsByAreaIDs(db, areaIDs) + if err != nil { + return ScopeFilter{}, err + } + locationIDs = derived + } + + locationIDs = uniqueUint(locationIDs) + if len(locationIDs) == 0 { + return ScopeFilter{Restrict: true}, nil + } + return ScopeFilter{IDs: locationIDs, Restrict: true}, nil +} + +func collectRoleScope(c *fiber.Ctx) (roleScope, error) { + ctx, ok := AuthDetails(c) + if !ok || ctx == nil || len(ctx.Roles) == 0 { + return roleScope{}, nil + } + + clientAlias := resolveClientAlias(ctx) + + scope := roleScope{} + areaSet := make(map[uint]struct{}) + locationSet := make(map[uint]struct{}) + + for _, role := range ctx.Roles { + if clientAlias != "" && !strings.EqualFold(strings.TrimSpace(role.ClientAlias), clientAlias) { + continue + } + scope.hasAnyScopes = true + if role.AllArea { + scope.allArea = true + } + if role.AllLocation { + scope.allLocation = true + } + for _, id := range role.AreaIDs { + if id == 0 { + continue + } + areaSet[id] = struct{}{} + } + for _, id := range role.LocationIDs { + if id == 0 { + continue + } + locationSet[id] = struct{}{} + } + } + + scope.areaIDs = keysUint(areaSet) + scope.locationIDs = keysUint(locationSet) + + scope.hasAnyScopes = scope.hasAnyScopes && + (scope.allArea || scope.allLocation || len(scope.areaIDs) > 0 || len(scope.locationIDs) > 0) + + return scope, nil +} + +func areaIDsByLocationIDs(db *gorm.DB, locationIDs []uint) ([]uint, error) { + if db == nil { + return nil, errors.New("database not configured") + } + if len(locationIDs) == 0 { + return nil, nil + } + var areaIDs []uint + if err := db.Model(&entity.Location{}). + Where("deleted_at IS NULL"). + Where("id IN ?", locationIDs). + Distinct("area_id"). + Pluck("area_id", &areaIDs).Error; err != nil { + return nil, err + } + return areaIDs, nil +} + +func locationIDsByAreaIDs(db *gorm.DB, areaIDs []uint) ([]uint, error) { + if db == nil { + return nil, errors.New("database not configured") + } + if len(areaIDs) == 0 { + return nil, nil + } + var locationIDs []uint + if err := db.Model(&entity.Location{}). + Where("deleted_at IS NULL"). + Where("area_id IN ?", areaIDs). + Distinct("id"). + Pluck("id", &locationIDs).Error; err != nil { + return nil, err + } + return locationIDs, nil +} + +func filterLocationIDsByAreaIDs(db *gorm.DB, locationIDs, areaIDs []uint) ([]uint, error) { + if db == nil { + return nil, errors.New("database not configured") + } + if len(locationIDs) == 0 || len(areaIDs) == 0 { + return nil, nil + } + var filtered []uint + if err := db.Model(&entity.Location{}). + Where("deleted_at IS NULL"). + Where("id IN ?", locationIDs). + Where("area_id IN ?", areaIDs). + Distinct("id"). + Pluck("id", &filtered).Error; err != nil { + return nil, err + } + return filtered, nil +} + +func uniqueUint(ids []uint) []uint { + if len(ids) == 0 { + return nil + } + seen := make(map[uint]struct{}, len(ids)) + result := make([]uint, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} + +func keysUint(set map[uint]struct{}) []uint { + if len(set) == 0 { + return nil + } + out := make([]uint, 0, len(set)) + for id := range set { + out = append(out, id) + } + return out +} + +func resolveClientAlias(ctx *AuthContext) string { + if ctx == nil || ctx.Verification == nil || ctx.Verification.Claims == nil { + return "" + } + + scopes := ctx.Verification.Claims.Scopes() + if len(scopes) == 0 { + return "" + } + + seen := make(map[string]struct{}) + for _, scope := range scopes { + scope = strings.ToLower(strings.TrimSpace(scope)) + if scope == "" { + continue + } + prefix := scope + if idx := strings.IndexAny(prefix, ".:"); idx > 0 { + prefix = prefix[:idx] + } + prefix = strings.TrimSpace(prefix) + if prefix == "" { + continue + } + if alias := matchAlias(prefix); alias != "" { + seen[alias] = struct{}{} + } + } + + if len(seen) != 1 { + return "" + } + for alias := range seen { + return alias + } + return "" +} + +func matchAlias(alias string) string { + alias = strings.ToLower(strings.TrimSpace(alias)) + if alias == "" { + return "" + } + if _, ok := config.SSOClients[alias]; ok { + return alias + } + for key := range config.SSOClients { + if strings.EqualFold(key, alias) { + return strings.ToLower(strings.TrimSpace(key)) + } + } + return "" +} + +func ApplyScopeFilter(db *gorm.DB, scope ScopeFilter, column string) *gorm.DB { + if db == nil || !scope.Restrict { + return db + } + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + return db.Where(column+" IN ?", scope.IDs) +} diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index f306c74d..64802560 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -9,7 +9,7 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations" phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" @@ -456,7 +456,7 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i updateBody["reject_reason"] = *req.RejectReason } - actorID, err := middleware.ActorIDFromContext(c) + actorID, err := m.ActorIDFromContext(c) if err != nil { return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") } @@ -946,6 +946,11 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit buildBase := func() *gorm.DB { @@ -962,6 +967,8 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report Where("EXTRACT(YEAR FROM dc.date) = ?", params.Year). Where("dc.status = ?", "APPROVED") + db = m.ApplyScopeFilter(db, scope, "loc.id") + if params.AreaID != nil { db = db.Where("a.id = ?", *params.AreaID) } diff --git a/internal/modules/dashboards/controllers/dashboard.controller.go b/internal/modules/dashboards/controllers/dashboard.controller.go index bebad10f..f74f31a7 100644 --- a/internal/modules/dashboards/controllers/dashboard.controller.go +++ b/internal/modules/dashboards/controllers/dashboard.controller.go @@ -6,6 +6,7 @@ import ( "strings" "time" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" @@ -81,6 +82,20 @@ func (u *DashboardController) GetAll(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid include") } + scope, err := m.ResolveLocationScope(c, u.DashboardService.DB()) + if err != nil { + return err + } + if scope.Restrict { + if len(scope.IDs) == 0 { + lokasiIds = []uint{} + } else if len(lokasiIds) > 0 { + lokasiIds = intersectUint(lokasiIds, scope.IDs) + } else { + lokasiIds = scope.IDs + } + } + analysisMode := strings.ToUpper(strings.TrimSpace(c.Query("analysis_mode", validation.AnalysisModeOverview))) metric := strings.ToLower(strings.TrimSpace(c.Query("metric", ""))) @@ -176,6 +191,23 @@ func defaultUintSlice(values []uint) []uint { return values } +func intersectUint(a, b []uint) []uint { + if len(a) == 0 || len(b) == 0 { + return nil + } + set := make(map[uint]struct{}, len(b)) + for _, id := range b { + set[id] = struct{}{} + } + out := make([]uint, 0, len(a)) + for _, id := range a { + if _, ok := set[id]; ok { + out = append(out, id) + } + } + return out +} + func parsePeriodDates(startDateRaw, endDateRaw string, location *time.Location) (time.Time, time.Time, time.Time, error) { now := time.Now().In(location) startDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index b4635b2e..afdbd1c1 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -17,10 +17,12 @@ import ( "github.com/go-playground/validator/v10" "github.com/sirupsen/logrus" + "gorm.io/gorm" ) type DashboardService interface { GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) + DB() *gorm.DB } type dashboardService struct { @@ -37,6 +39,10 @@ func NewDashboardService(repo repository.DashboardRepository, validate *validato } } +func (s dashboardService) DB() *gorm.DB { + return s.Repository.DB() +} + func (s dashboardService) GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return dto.DashboardPerformanceOverviewDTO{}, 0, err diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 60ec97a7..504e65ad 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -138,9 +138,28 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context locationID := filters.LocationId areaID := filters.AreaId - if locationID > 0 || areaID > 0 { + if filters.AllowedLocationIDs != nil || filters.AllowedAreaIDs != nil || locationID > 0 || areaID > 0 { db = db.Joins("JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id") + } + if filters.AllowedLocationIDs != nil { + if len(filters.AllowedLocationIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("kandangs.location_id IN ?", filters.AllowedLocationIDs) + } + } + + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Joins("JOIN locations ON locations.id = kandangs.location_id"). + Where("locations.area_id IN ?", filters.AllowedAreaIDs) + } + } + + if locationID > 0 || areaID > 0 { if locationID > 0 { db = db.Where("kandangs.location_id = ?", uint(locationID)) } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 3bf2db55..27b4a07f 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -87,10 +87,16 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens return nil, 0, err } + scope, err := middleware.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = middleware.ApplyScopeFilter(db, scope, "location_id") if params.Search != "" { return db.Where("category ILIKE ?", "%"+params.Search+"%") } @@ -117,7 +123,16 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens } func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) { - expense, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := middleware.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + db = middleware.ApplyScopeFilter(db, scope, "location_id") + return db + }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/modules/inventory/product-stocks/services/product-stock.service.go b/internal/modules/inventory/product-stocks/services/product-stock.service.go index 11475109..a4e404d6 100644 --- a/internal/modules/inventory/product-stocks/services/product-stock.service.go +++ b/internal/modules/inventory/product-stocks/services/product-stock.service.go @@ -4,6 +4,7 @@ import ( "errors" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/validations" productRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -61,15 +62,34 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.ProductRepository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit productStocks, total, err := s.ProductRepository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = db.Where(`EXISTS ( - SELECT 1 - FROM product_warehouses pw - WHERE pw.product_id = products.id - AND pw.qty > 0 - )`) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where(`EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN warehouses w ON w.id = pw.warehouse_id + WHERE pw.product_id = products.id + AND pw.qty > 0 + AND w.location_id IN ? + )`, scope.IDs) + } else { + db = db.Where(`EXISTS ( + SELECT 1 + FROM product_warehouses pw + WHERE pw.product_id = products.id + AND pw.qty > 0 + )`) + } db = s.withRelations(db) if params.Search != "" { @@ -86,6 +106,30 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e } func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, error) { + scope, err := m.ResolveLocationScope(c, s.ProductRepository.DB()) + if err != nil { + return nil, err + } + + if scope.Restrict { + if len(scope.IDs) == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + var count int64 + if err := s.ProductRepository.DB().WithContext(c.Context()). + Table("product_warehouses pw"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Where("pw.product_id = ?", id). + Where("pw.qty > 0"). + Where("w.location_id IN ?", scope.IDs). + Count(&count).Error; err != nil { + return nil, err + } + if count == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + } + product, err := s.ProductRepository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 152bfa24..5c162084 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -8,7 +8,7 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" @@ -53,6 +53,11 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + if params.ProductId > 0 { isProductExist, err := s.Repository.IsProductExist(c.Context(), params.ProductId) if err != nil { @@ -90,6 +95,14 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id"). + Where("w_scope.location_id IN ?", scope.IDs) + } + if params.ProductId != 0 { db = db.Where("product_id = ?", params.ProductId) } @@ -116,7 +129,22 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) } func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductWarehouse, error) { - productWarehouse, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + productWarehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id"). + Where("w_scope.location_id IN ?", scope.IDs) + } + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "ProductWarehouse not found") } diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 3f12b444..b4652a5d 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -94,10 +94,24 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db. + Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). + Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id"). + Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs) + } if params.Search != "" { db = db.Where("movement_number ILIKE ?", "%"+strings.TrimSpace(params.Search)+"%") } @@ -112,6 +126,28 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit } func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { + scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB()) + if err != nil { + return nil, err + } + if scope.Restrict { + if len(scope.IDs) == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + var count int64 + if err := s.StockTransferRepo.DB().WithContext(c.Context()). + Table("stock_transfers"). + Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). + Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id"). + Where("stock_transfers.id = ?", id). + Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs). + Count(&count).Error; err != nil { + return nil, err + } + if count == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + } transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.withRelations(db) diff --git a/internal/modules/master/areas/services/area.service.go b/internal/modules/master/areas/services/area.service.go index e6f9205c..1ec30d8d 100644 --- a/internal/modules/master/areas/services/area.service.go +++ b/internal/modules/master/areas/services/area.service.go @@ -47,10 +47,21 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar return nil, 0, err } + scope, err := m.ResolveAreaScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit areas, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("id IN ?", scope.IDs) + } if params.Search != "" { return db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -65,7 +76,21 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar } func (s areaService) GetOne(c *fiber.Ctx, id uint) (*entity.Area, error) { - area, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveAreaScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + area, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("id IN ?", scope.IDs) + } + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Area not found") } diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index 9f83f0ce..b70577d6 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -49,10 +49,16 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit kandangs, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = m.ApplyScopeFilter(db, scope, "location_id") if params.Search != "" { return db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -73,7 +79,16 @@ func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity } func (s kandangService) GetOne(c *fiber.Ctx, id uint) (*entity.Kandang, error) { - kandang, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + kandang, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + db = m.ApplyScopeFilter(db, scope, "location_id") + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Kandang not found") } diff --git a/internal/modules/master/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go index 3a1d1e23..633d7419 100644 --- a/internal/modules/master/locations/services/location.service.go +++ b/internal/modules/master/locations/services/location.service.go @@ -47,10 +47,21 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit locations, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("id IN ?", scope.IDs) + } if params.Search != "" { db = db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -68,7 +79,21 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit } func (s locationService) GetOne(c *fiber.Ctx, id uint) (*entity.Location, error) { - location, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + location, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("id IN ?", scope.IDs) + } + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Location not found") } diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 7eeaad3d..aaa5ca7e 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -48,10 +48,16 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, err } + scope, err := m.ResolveAreaScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = m.ApplyScopeFilter(db, scope, "area_id") if params.Search != "" { db = db.Where("warehouses.name ILIKE ?", "%"+params.Search+"%") } @@ -86,7 +92,16 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } func (s warehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.Warehouse, error) { - warehouse, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + scope, err := m.ResolveAreaScope(c, s.Repository.DB()) + if err != nil { + return nil, err + } + + warehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + db = m.ApplyScopeFilter(db, scope, "area_id") + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") } diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 6f019ffa..61a593d5 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -88,9 +88,14 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit - projectFlockKandangs, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) + projectFlockKandangs, total, err := s.Repository.GetAllWithFiltersScoped(c.Context(), offset, params.Limit, params, scope.IDs, scope.Restrict) if err != nil { s.Log.Errorf("Failed to get projectFlockKandangs: %+v", err) @@ -106,6 +111,28 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer } func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error) { + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, nil, nil, err + } + if scope.Restrict { + if len(scope.IDs) == 0 { + return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + var count int64 + if err := s.Repository.DB().WithContext(c.Context()). + Table("project_flock_kandangs"). + Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). + Where("project_flock_kandangs.id = ?", id). + Where("project_flocks.location_id IN ?", scope.IDs). + Count(&count).Error; err != nil { + return nil, nil, nil, err + } + if count == 0 { + return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + } + projectFlockKandang, err := s.Repository.GetByID(c.Context(), id) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index e65dfb4a..346f2176 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -14,6 +14,7 @@ import ( type ProjectflockRepository interface { repository.BaseRepository[entity.ProjectFlock] GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) + GetAllWithFiltersScoped(ctx context.Context, offset, limit int, params *validation.Query, locationIDs []uint, restrict bool) ([]entity.ProjectFlock, int64, error) WithDefaultRelations() func(*gorm.DB) *gorm.DB ExistsByFlockName(ctx context.Context, flockName string, excludeID *uint) (bool, error) GetNextPeriodsForKandangs(ctx context.Context, kandangIDs []uint) (map[uint]int, error) @@ -48,6 +49,19 @@ func (r *ProjectflockRepositoryImpl) GetAllWithFilters(ctx context.Context, offs }) } +func (r *ProjectflockRepositoryImpl) GetAllWithFiltersScoped(ctx context.Context, offset, limit int, params *validation.Query, locationIDs []uint, restrict bool) ([]entity.ProjectFlock, int64, error) { + return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { + db = r.applyQueryFilters(r.WithDefaultRelations()(db), params) + if restrict { + if len(locationIDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where("project_flocks.location_id IN ?", locationIDs) + } + return db + }) +} + func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db. diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 474a53c2..24c4c083 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -20,6 +20,7 @@ type ProjectFlockKandangRepository interface { DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) + GetAllWithFiltersScoped(ctx context.Context, offset int, limit int, params interface{}, locationIDs []uint, restrict bool) ([]entity.ProjectFlockKandang, int64, error) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectFlockKandang, error) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) @@ -196,6 +197,104 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Contex return records, total, nil } +func (r *projectFlockKandangRepositoryImpl) GetAllWithFiltersScoped(ctx context.Context, offset int, limit int, params interface{}, locationIDs []uint, restrict bool) ([]entity.ProjectFlockKandang, int64, error) { + var records []entity.ProjectFlockKandang + var total int64 + + query, ok := params.(*validation.Query) + + q := r.db.WithContext(ctx). + Joins("JOIN \"kandangs\" ON \"project_flock_kandangs\".\"kandang_id\" = \"kandangs\".\"id\""). + Joins("JOIN \"project_flocks\" ON \"project_flock_kandangs\".\"project_flock_id\" = \"project_flocks\".\"id\""). + Preload("ProjectFlock"). + Preload("ProjectFlock.Fcr"). + Preload("ProjectFlock.Area"). + Preload("ProjectFlock.Location"). + Preload("ProjectFlock.CreatedUser"). + Preload("ProjectFlock.Kandangs"). + Preload("ProjectFlock.KandangHistory"). + Preload("Kandang"). + Preload("Chickins"). + Preload("Chickins.CreatedUser"). + Preload("Chickins.ProductWarehouse") + + if restrict { + if len(locationIDs) == 0 { + return []entity.ProjectFlockKandang{}, 0, nil + } + q = q.Where("\"project_flocks\".\"location_id\" IN ?", locationIDs) + } + + if ok && query != nil && query.StepName != "" { + q = q.Where(` + EXISTS ( + SELECT 1 FROM "approvals" + WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id" + AND "approvals"."approvable_type" = ? + AND LOWER("approvals"."step_name") = LOWER(?) + AND "approvals"."id" IN ( + SELECT "approvals"."id" FROM "approvals" + WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id" + AND "approvals"."approvable_type" = ? + ORDER BY "approvals"."id" DESC + LIMIT 1 + ) + ) + `, "PROJECT_FLOCK_KANDANGS", query.StepName, "PROJECT_FLOCK_KANDANGS") + } + + if ok && query != nil { + if query.Search != "" { + escapedSearch := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(query.Search) + q = q.Where( + r.db.Where("LOWER(\"kandangs\".\"name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%"). + Or("LOWER(\"project_flocks\".\"flock_name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%"), + ) + } + + if query.ProjectFlockId > 0 { + q = q.Where("\"project_flock_kandangs\".\"project_flock_id\" = ?", query.ProjectFlockId) + } + + if query.KandangId > 0 { + q = q.Where("\"project_flock_kandangs\".\"kandang_id\" = ?", query.KandangId) + } + + if query.Category != "" { + q = q.Where("\"project_flocks\".\"category\" = ?", query.Category) + } + + if query.AreaId > 0 { + q = q.Where("\"project_flocks\".\"area_id\" = ?", query.AreaId) + } + } + + if err := q.Model(&entity.ProjectFlockKandang{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + sortBy := "\"project_flock_kandangs\".\"created_at\" DESC" + if ok && query != nil && query.SortBy != "" { + sortOrder := "DESC" + if query.SortOrder == "ASC" { + sortOrder = "ASC" + } + + switch query.SortBy { + case "created_at": + sortBy = "\"project_flock_kandangs\".\"created_at\" " + sortOrder + case "period": + sortBy = "\"project_flocks\".\"period\" " + sortOrder + } + } + + if err := q.Order(sortBy).Offset(offset).Limit(limit).Find(&records).Error; err != nil { + return nil, 0, err + } + + return records, total, nil +} + func (r *projectFlockKandangRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockKandangRepository { return &projectFlockKandangRepositoryImpl{db: tx} } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 3dbe3f4b..5bccfbf9 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -114,9 +114,14 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e return nil, 0, nil, err } + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, nil, err + } + offset := (params.Page - 1) * params.Limit - projectflocks, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) + projectflocks, total, err := s.Repository.GetAllWithFiltersScoped(c.Context(), offset, params.Limit, params, scope.IDs, scope.Restrict) if err != nil { s.Log.Errorf("Failed to get projectflocks: %+v", err) @@ -190,7 +195,16 @@ func (s projectflockService) getOneEntityOnly(c *fiber.Ctx, id uint) (*entity.Pr } func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, *flockDTO.FlockRelationDTO, error) { - projectflock, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, nil, err + } + + projectflock, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.Repository.WithDefaultRelations()(db) + db = m.ApplyScopeFilter(db, scope, "project_flocks.location_id") + return db + }) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 35ca2f75..6b9537cc 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -124,6 +124,11 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti return nil, 0, err } + scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB()) + if err != nil { + return nil, 0, err + } + offset := (params.Page - 1) * params.Limit createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo) @@ -147,6 +152,21 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti db = db.Where("created_at < ?", *createdTo) } + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where( + `EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN warehouses w ON w.id = pi.warehouse_id + WHERE pi.purchase_id = purchases.id AND w.location_id IN ? + )`, + scope.IDs, + ) + } + if params.AreaID > 0 { db = db.Where( `EXISTS ( @@ -201,7 +221,42 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) { - return s.loadPurchase(c.Context(), id) + scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB()) + if err != nil { + return nil, err + } + + purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if scope.Restrict { + if len(scope.IDs) == 0 { + return db.Where("1 = 0") + } + db = db.Where( + `EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN warehouses w ON w.id = pi.warehouse_id + WHERE pi.purchase_id = purchases.id AND w.location_id IN ? + )`, + scope.IDs, + ) + } + return db + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, utils.NotFound("Purchase not found") + } + s.Log.Errorf("Failed to get purchase %d: %+v", id, err) + return nil, utils.Internal("Failed to get purchase") + } + if err := s.attachLatestApproval(c.Context(), purchase); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) + } + s.applyTravelDocumentURLs(c.Context(), purchase) + + return purchase, nil } func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) { diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 1d273af1..4ed7a855 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" @@ -49,6 +50,21 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { RealizationDate: ctx.Query("realization_date", ""), } + locationScope, err := m.ResolveLocationScope(ctx, c.RepportService.DB()) + if err != nil { + return err + } + areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB()) + if err != nil { + return err + } + if locationScope.Restrict { + query.AllowedLocationIDs = toInt64Slice(locationScope.IDs) + } + if areaScope.Restrict { + query.AllowedAreaIDs = toInt64Slice(areaScope.IDs) + } + if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } @@ -130,6 +146,14 @@ func (c *RepportController) GetPurchaseSupplier(ctx *fiber.Ctx) error { FilterBy: ctx.Query("filter_by", ""), } + areaScope, err := m.ResolveAreaScope(ctx, c.RepportService.DB()) + if err != nil { + return err + } + if areaScope.Restrict { + query.AllowedAreaIDs = toInt64Slice(areaScope.IDs) + } + if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } @@ -306,3 +330,14 @@ func parseCommaSeparatedInt64s(raw string) ([]int64, error) { return result, nil } + +func toInt64Slice(ids []uint) []int64 { + if len(ids) == 0 { + return nil + } + out := make([]int64, 0, len(ids)) + for _, id := range ids { + out = append(out, int64(id)) + } + return out +} diff --git a/internal/modules/repports/repositories/purchase_supplier.repository.go b/internal/modules/repports/repositories/purchase_supplier.repository.go index 979623fc..048fe02c 100644 --- a/internal/modules/repports/repositories/purchase_supplier.repository.go +++ b/internal/modules/repports/repositories/purchase_supplier.repository.go @@ -53,10 +53,18 @@ func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, Where("products.product_category_id = ?", filters.ProductCategoryId) } - if filters.AreaId > 0 { - db = db. - Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id"). - Where("warehouses.area_id = ?", filters.AreaId) + if filters.AreaId > 0 || filters.AllowedAreaIDs != nil { + db = db.Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id") + if filters.AreaId > 0 { + db = db.Where("warehouses.area_id = ?", filters.AreaId) + } + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("warehouses.area_id IN ?", filters.AllowedAreaIDs) + } + } } if filters.StartDate != "" { @@ -164,10 +172,18 @@ func (r *purchaseSupplierRepositoryImpl) GetItemsBySuppliers(ctx context.Context Where("products.product_category_id = ?", filters.ProductCategoryId) } - if filters.AreaId > 0 { - db = db. - Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id"). - Where("warehouses.area_id = ?", filters.AreaId) + if filters.AreaId > 0 || filters.AllowedAreaIDs != nil { + db = db.Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id") + if filters.AreaId > 0 { + db = db.Where("warehouses.area_id = ?", filters.AreaId) + } + if filters.AllowedAreaIDs != nil { + if len(filters.AllowedAreaIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("warehouses.area_id IN ?", filters.AllowedAreaIDs) + } + } } if filters.StartDate != "" { diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c4883b72..0e3dbada 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -9,6 +9,7 @@ import ( "strings" "time" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" @@ -40,6 +41,7 @@ type RepportService interface { GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) + DB() *gorm.DB } type repportService struct { @@ -95,6 +97,10 @@ func NewRepportService( } } +func (s *repportService) DB() *gorm.DB { + return s.ExpenseRealizationRepo.DB() +} + func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -1303,6 +1309,36 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) } + locationScope, err := m.ResolveLocationScope(ctx, s.ExpenseRealizationRepo.DB()) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, err + } + areaScope, err := m.ResolveAreaScope(ctx, s.ExpenseRealizationRepo.DB()) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, err + } + + if locationScope.Restrict { + allowed := toInt64Slice(locationScope.IDs) + if len(allowed) == 0 { + locationIDs = []int64{-1} + } else if len(locationIDs) > 0 { + locationIDs = intersectInt64(locationIDs, allowed) + } else { + locationIDs = allowed + } + } + if areaScope.Restrict { + allowed := toInt64Slice(areaScope.IDs) + if len(allowed) == 0 { + areaIDs = []int64{-1} + } else if len(areaIDs) > 0 { + areaIDs = intersectInt64(areaIDs, allowed) + } else { + areaIDs = allowed + } + } + weightMin, err := parseOptionalFloat64(rawWeightMin) if err != nil { return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) @@ -1366,6 +1402,34 @@ func parseCommaSeparatedInt64s(raw string) ([]int64, error) { return result, nil } +func toInt64Slice(ids []uint) []int64 { + if len(ids) == 0 { + return nil + } + out := make([]int64, 0, len(ids)) + for _, id := range ids { + out = append(out, int64(id)) + } + return out +} + +func intersectInt64(a, b []int64) []int64 { + if len(a) == 0 || len(b) == 0 { + return nil + } + set := make(map[int64]struct{}, len(b)) + for _, id := range b { + set[id] = struct{}{} + } + out := make([]int64, 0, len(a)) + for _, id := range a { + if _, ok := set[id]; ok { + out = append(out, id) + } + } + return out +} + func parseOptionalFloat64(raw string) (*float64, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 6d50f3e6..4c1d7356 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -13,6 +13,8 @@ type ExpenseQuery struct { AreaId int64 `query:"area_id" validate:"omitempty"` LocationId int64 `query:"location_id" validate:"omitempty"` RealizationDate string `query:"realization_date" validate:"omitempty"` + AllowedAreaIDs []int64 `query:"-"` + AllowedLocationIDs []int64 `query:"-"` } type MarketingQuery struct { @@ -42,6 +44,7 @@ type PurchaseSupplierQuery struct { EndDate string `query:"end_date" validate:"omitempty"` SortBy string `query:"sort_by" validate:"omitempty"` FilterBy string `query:"filter_by" validate:"omitempty"` + AllowedAreaIDs []int64 `query:"-"` } type DebtSupplierQuery struct { diff --git a/internal/modules/sso/controllers/master_data.controller.go b/internal/modules/sso/controllers/master_data.controller.go new file mode 100644 index 00000000..2364011f --- /dev/null +++ b/internal/modules/sso/controllers/master_data.controller.go @@ -0,0 +1,331 @@ +package controllers + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" + "gitlab.com/mbugroup/lti-api.git/internal/response" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type MasterDataController struct { + db *gorm.DB + redis *redis.Client + clients map[string]config.SSOClientConfig + drift time.Duration + nonceTTL time.Duration + localNonce sync.Map +} + +type masterArea struct { + ID uint `json:"id"` + Name string `json:"name"` +} + +type masterLocation struct { + ID uint `json:"id"` + Name string `json:"name"` + AreaID uint `json:"area_id"` +} + +func NewMasterDataController(db *gorm.DB, redis *redis.Client, clients map[string]config.SSOClientConfig) *MasterDataController { + normalized := make(map[string]config.SSOClientConfig, len(clients)) + for alias, cfg := range clients { + alias = strings.ToLower(strings.TrimSpace(alias)) + normalized[alias] = cfg + } + + drift := config.SSOUserSyncDrift + if drift <= 0 { + drift = 2 * time.Minute + } + + nonceTTL := config.SSOUserSyncNonceTTL + if nonceTTL <= 0 { + nonceTTL = 10 * time.Minute + } + + return &MasterDataController{ + db: db, + redis: redis, + clients: normalized, + drift: drift, + nonceTTL: nonceTTL, + } +} + +func (h *MasterDataController) GetAreas(c *fiber.Ctx) error { + if _, _, err := h.authenticate(c, nil); err != nil { + return err + } + + search := strings.TrimSpace(c.Query("search", "")) + ids := parseUintList(c.Query("ids", "")) + + query := h.db.WithContext(c.Context()). + Model(&entity.Area{}). + Where("deleted_at IS NULL") + if search != "" { + query = query.Where("name ILIKE ?", "%"+search+"%") + } + if len(ids) > 0 { + query = query.Where("id IN ?", ids) + } + + var areas []masterArea + if err := query.Order("name ASC").Find(&areas).Error; err != nil { + utils.Log.WithError(err).Error("failed to fetch areas for master data") + return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch areas") + } + + return c.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get areas successfully", + Data: areas, + }) +} + +func (h *MasterDataController) GetLocations(c *fiber.Ctx) error { + if _, _, err := h.authenticate(c, nil); err != nil { + return err + } + + search := strings.TrimSpace(c.Query("search", "")) + areaIDs := parseUintList(c.Query("area_ids", "")) + ids := parseUintList(c.Query("ids", "")) + + query := h.db.WithContext(c.Context()). + Model(&entity.Location{}). + Where("deleted_at IS NULL") + if search != "" { + query = query.Where("name ILIKE ?", "%"+search+"%") + } + if len(areaIDs) > 0 { + query = query.Where("area_id IN ?", areaIDs) + } + if len(ids) > 0 { + query = query.Where("id IN ?", ids) + } + + var locations []masterLocation + if err := query.Order("name ASC").Find(&locations).Error; err != nil { + utils.Log.WithError(err).Error("failed to fetch locations for master data") + return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch locations") + } + + return c.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get locations successfully", + Data: locations, + }) +} + +func (h *MasterDataController) authenticate(c *fiber.Ctx, body []byte) (string, config.SSOClientConfig, error) { + rawAlias := strings.TrimSpace(c.Get("X-Sync-Client")) + if rawAlias == "" { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "missing sync client header") + } + + aliasKey := strings.ToLower(rawAlias) + clientCfg, ok := h.clients[aliasKey] + if !ok { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "unknown sync client") + } + + if err := h.verifyAuthorization(c, aliasKey); err != nil { + return "", config.SSOClientConfig{}, err + } + + secret := strings.TrimSpace(clientCfg.SyncSecret) + if secret == "" { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "sync secret not configured") + } + + timestamp := strings.TrimSpace(c.Get("X-Sync-Timestamp")) + nonce := strings.TrimSpace(c.Get("X-Sync-Nonce")) + signature := strings.TrimSpace(c.Get("X-Sync-Signature")) + if timestamp == "" || nonce == "" || signature == "" { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "missing signature headers") + } + if len(nonce) < 16 { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "nonce too short") + } + + ts, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusBadRequest, "invalid timestamp") + } + + msgTime := time.Unix(ts, 0).UTC() + now := time.Now().UTC() + drift := now.Sub(msgTime) + if drift > h.drift || drift < -h.drift { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "timestamp outside allowed window") + } + + providedSig, err := decodeMasterSignature(signature) + if err != nil { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "invalid signature encoding") + } + + expectedSignature := calculateSignature(secret, rawAlias, timestamp, nonce, body) + if !hmac.Equal(providedSig, expectedSignature) { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "invalid signature") + } + + if err := h.registerNonce(c.Context(), aliasKey, nonce); err != nil { + return "", config.SSOClientConfig{}, err + } + + return aliasKey, clientCfg, nil +} + +func (h *MasterDataController) verifyAuthorization(c *fiber.Ctx, alias string) error { + authHeader := strings.TrimSpace(c.Get(fiber.HeaderAuthorization)) + if authHeader == "" { + return fiber.NewError(fiber.StatusUnauthorized, "missing authorization header") + } + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return fiber.NewError(fiber.StatusUnauthorized, "invalid authorization header") + } + + token := strings.TrimSpace(parts[1]) + if token == "" { + return fiber.NewError(fiber.StatusUnauthorized, "invalid authorization header") + } + + verification, err := sso.VerifyAccessToken(token) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") + } + + if verification.ServiceAlias == "" || verification.ServiceAlias != alias { + return fiber.NewError(fiber.StatusUnauthorized, "service subject mismatch") + } + if !hasAnyScope(verification.Claims.Scopes(), []string{"sync.master", "sync.users"}) { + return fiber.NewError(fiber.StatusForbidden, "missing sync scope") + } + return nil +} + +func (h *MasterDataController) registerNonce(ctx context.Context, alias, nonce string) error { + ttl := h.nonceTTL + if ttl <= 0 { + ttl = 10 * time.Minute + } + + key := fmt.Sprintf("sso:sync:%s:%s", alias, nonce) + if h.redis != nil { + stored, err := h.redis.SetNX(ctx, key, "1", ttl).Result() + if err == nil { + if !stored { + return fiber.NewError(fiber.StatusUnauthorized, "nonce already used") + } + return nil + } + utils.Log.WithError(err).Warn("store sync nonce failed") + } + + now := time.Now().UTC() + if expRaw, ok := h.localNonce.Load(key); ok { + if expTime, ok := expRaw.(time.Time); ok && expTime.After(now) { + return fiber.NewError(fiber.StatusUnauthorized, "nonce already used") + } + } + h.localNonce.Store(key, now.Add(ttl)) + return nil +} + +func calculateSignature(secret, alias, timestamp, nonce string, body []byte) []byte { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(alias)) + mac.Write([]byte("\n")) + mac.Write([]byte(timestamp)) + mac.Write([]byte("\n")) + mac.Write([]byte(nonce)) + mac.Write([]byte("\n")) + if len(body) > 0 { + mac.Write(body) + } + return mac.Sum(nil) +} + +func decodeMasterSignature(sig string) ([]byte, error) { + sig = strings.TrimSpace(sig) + if sig == "" { + return nil, errors.New("empty signature") + } + if decoded, err := hex.DecodeString(sig); err == nil { + return decoded, nil + } + if decoded, err := base64.StdEncoding.DecodeString(sig); err == nil { + return decoded, nil + } + if decoded, err := base64.URLEncoding.DecodeString(sig); err == nil { + return decoded, nil + } + return nil, errors.New("unrecognized signature encoding") +} + +func parseUintList(raw string) []uint { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]uint, 0, len(parts)) + seen := make(map[uint]struct{}, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + val, err := strconv.ParseUint(part, 10, 64) + if err != nil || val == 0 { + continue + } + if _, ok := seen[uint(val)]; ok { + continue + } + seen[uint(val)] = struct{}{} + out = append(out, uint(val)) + } + return out +} + +func hasAnyScope(scopes []string, targets []string) bool { + if len(scopes) == 0 || len(targets) == 0 { + return false + } + for _, scope := range scopes { + scope = strings.ToLower(strings.TrimSpace(scope)) + if scope == "" { + continue + } + for _, target := range targets { + if scope == strings.ToLower(strings.TrimSpace(target)) { + return true + } + } + } + return false +} diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index 410e9577..b49d73e5 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -16,7 +16,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" - "gitlab.com/mbugroup/lti-api.git/internal/sso" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/secure" ) diff --git a/internal/modules/sso/controllers/user_sync.controller.go b/internal/modules/sso/controllers/user_sync.controller.go index 9aeb9555..72c7768a 100644 --- a/internal/modules/sso/controllers/user_sync.controller.go +++ b/internal/modules/sso/controllers/user_sync.controller.go @@ -9,23 +9,24 @@ import ( "encoding/json" "errors" "fmt" - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "github.com/redis/go-redis/v9" - "github.com/sirupsen/logrus" - "gorm.io/gorm" "strconv" "strings" "sync" "time" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" + sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier" "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" userRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" "gitlab.com/mbugroup/lti-api.git/internal/response" - "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" ) diff --git a/internal/modules/sso/route.go b/internal/modules/sso/route.go index 3f2a699e..a864611e 100644 --- a/internal/modules/sso/route.go +++ b/internal/modules/sso/route.go @@ -26,6 +26,7 @@ func Routes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { ctrl := ssoController.NewController(&http.Client{Timeout: 10 * time.Second}, store, session.GetRevocationStore()) userRepo := userRepository.NewUserRepository(db) syncCtrl := ssoController.NewUserSyncController(validate, userRepo, cache.Redis(), config.SSOClients) + masterCtrl := ssoController.NewMasterDataController(db, cache.Redis(), config.SSOClients) group := router.Group("/sso") group.Get("/start", middleware.NewLimiter(30, time.Minute), ctrl.Start) @@ -34,4 +35,6 @@ func Routes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { group.Post("/refresh", middleware.NewLimiter(60, time.Minute), ctrl.Refresh) group.Post("/logout", middleware.NewLimiter(60, time.Minute), ctrl.Logout) group.Post("/users/sync", middleware.NewLimiter(30, time.Minute), syncCtrl.Sync) + group.Get("/master/areas", middleware.NewLimiter(60, time.Minute), masterCtrl.GetAreas) + group.Get("/master/locations", middleware.NewLimiter(60, time.Minute), masterCtrl.GetLocations) } diff --git a/internal/sso/profile.go b/internal/modules/sso/verifier/profile.go similarity index 95% rename from internal/sso/profile.go rename to internal/modules/sso/verifier/profile.go index a211fc74..52b7ef5a 100644 --- a/internal/sso/profile.go +++ b/internal/modules/sso/verifier/profile.go @@ -49,6 +49,10 @@ type Role struct { ClientID uint ClientAlias string ClientName string + AllArea bool + AllLocation bool + AreaIDs []uint + LocationIDs []uint Permissions []Permission RawReference json.RawMessage `json:"-"` } @@ -162,6 +166,10 @@ func fetchProfileFromSSO(ctx context.Context, token string) (*UserProfile, error ClientAlias: strings.TrimSpace(r.Client.Alias), ClientName: strings.TrimSpace(r.Client.Name), ClientID: uint(r.Client.ID), + AllArea: r.AllArea, + AllLocation: r.AllLocation, + AreaIDs: r.AreaIDs, + LocationIDs: r.LocationIDs, } rolePerms := make([]Permission, 0, len(r.Permissions)) for _, p := range r.Permissions { @@ -288,6 +296,10 @@ type userInfoRole struct { ID int64 `json:"id"` Key string `json:"key"` Name string `json:"name"` + AllArea bool `json:"all_area"` + AllLocation bool `json:"all_location"` + AreaIDs []uint `json:"area_ids"` + LocationIDs []uint `json:"location_ids"` Client userInfoClient `json:"client"` Permissions []userInfoPermRaw `json:"permissions"` } diff --git a/internal/sso/verifier.go b/internal/modules/sso/verifier/verifier.go similarity index 100% rename from internal/sso/verifier.go rename to internal/modules/sso/verifier/verifier.go