From 1bdaf63763d99c4fa16404f511a349e8647492d4 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 6 Jan 2026 12:02:19 +0700 Subject: [PATCH] feat(BE-281): adjustment sso redirect,adjustment response closing,adjustment uniformity --- internal/config/config.go | 2 + .../closings/dto/closingSapronak.dto.go | 19 +- .../closings/services/sapronak.service.go | 6 +- .../services/projectflock.service.go | 21 -- .../controllers/uniformity.controller.go | 6 +- .../uniformities/dto/uniformity.dto.go | 18 ++ .../services/uniformity.service.go | 26 ++- .../modules/sso/controllers/sso.controller.go | 203 +++++++++++++++++- 8 files changed, 267 insertions(+), 34 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 5f76a9e0..8660704b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,6 +54,7 @@ var ( SSOAuthorizeURL string SSOTokenURL string SSOGetMeURL string + SSOPortalURL string SSOClients map[string]SSOClientConfig SSOAccessCookieName string SSORefreshCookieName string @@ -131,6 +132,7 @@ func init() { SSOAuthorizeURL = viper.GetString("SSO_AUTHORIZE_URL") SSOTokenURL = viper.GetString("SSO_TOKEN_URL") SSOGetMeURL = viper.GetString("SSO_GETME_URL") + SSOPortalURL = strings.TrimSpace(viper.GetString("SSO_PORTAL_URL")) SSOAccessCookieName = defaultString(viper.GetString("SSO_ACCESS_COOKIE_NAME"), "sso_access") SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh") SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN") diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 13044efd..768c727e 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -134,7 +134,14 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin report = &SapronakReportDTO{} } - filter := strings.ToUpper(strings.TrimSpace(flag)) + normalizeFlag := func(raw string) string { + normalized := strings.ToUpper(strings.TrimSpace(raw)) + if normalized == "PULLET" { + return "DOC" + } + return normalized + } + filter := normalizeFlag(flag) byFlag := map[string]**SapronakCategoryDTO{} if filter == "" || filter == "DOC" { @@ -149,10 +156,6 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin result.Pakan = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)} byFlag["PAKAN"] = &result.Pakan } - if filter == "" || filter == "PULLET" { - result.Pullet = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)} - byFlag["PULLET"] = &result.Pullet - } formatDate := func(t *time.Time) string { if t == nil { @@ -162,7 +165,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin } for _, group := range report.Groups { - flagKey := strings.ToUpper(group.Flag) + flagKey := normalizeFlag(group.Flag) ptr := byFlag[flagKey] if ptr == nil || *ptr == nil { continue @@ -182,7 +185,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin } for idx, item := range group.Items { - productKey := strings.ToUpper(group.Flag + "|" + item.ProductName) + productKey := strings.ToUpper(flagKey + "|" + item.ProductName) baseRow := SapronakCategoryRowDTO{ ID: idx + 1, Date: formatDate(item.Tanggal), @@ -246,7 +249,5 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin buildTotals(result.Doc, "TOTAL DOC") buildTotals(result.Ovk, "TOTAL OVK") buildTotals(result.Pakan, "TOTAL PAKAN") - buildTotals(result.Pullet, "TOTAL PULLET") - return result } diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index 3c1843dd..b923db5d 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -359,7 +359,11 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if filterFlag == "" { return true } - return strings.ToUpper(f) == filterFlag + candidate := strings.ToUpper(f) + if filterFlag == "DOC" || filterFlag == "PULLET" { + return candidate == "DOC" || candidate == "PULLET" + } + return candidate == filterFlag } // For project flocks with category GROWING, pullet usage from chickin diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index ec887eea..5f643dee 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -517,27 +517,6 @@ func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID u return total, nil } -// getProjectFlockClosingDate mengembalikan tanggal closing Project Flock jika sudah mencapai step SELESAI (Approved). -// func (s projectflockService) getProjectFlockClosingDate(ctx context.Context, projectFlockID uint) (*time.Time, error) { -// if projectFlockID == 0 || s.ApprovalSvc == nil { -// return nil, nil -// } - -// latest, err := s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowProjectFlock, projectFlockID, nil) -// if err != nil { -// return nil, err -// } -// if latest == nil || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved { -// return nil, nil -// } -// if latest.StepNumber != uint16(utils.ProjectFlockStepSelesai) { -// return nil, nil -// } - -// t := latest.ActionAt -// return &t, nil -// } - func (s projectflockService) GetProjectPeriods(c *fiber.Ctx, projectIDs []uint) (map[uint]int, error) { if len(projectIDs) == 0 { return map[uint]int{}, nil diff --git a/internal/modules/production/uniformities/controllers/uniformity.controller.go b/internal/modules/production/uniformities/controllers/uniformity.controller.go index ce91c3af..e18e7dce 100644 --- a/internal/modules/production/uniformities/controllers/uniformity.controller.go +++ b/internal/modules/production/uniformities/controllers/uniformity.controller.go @@ -36,6 +36,10 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error { if err != nil { return err } + documents, err := u.UniformityService.MapDocuments(c, result) + if err != nil { + return err + } return c.Status(fiber.StatusOK). JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{ @@ -53,7 +57,7 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error { "status": "Pengajuan", }, }, - Data: dto.ToUniformityListDTOsWithStandard(result, standards), + Data: dto.ToUniformityListDTOsWithStandard(result, standards, documents), }) } diff --git a/internal/modules/production/uniformities/dto/uniformity.dto.go b/internal/modules/production/uniformities/dto/uniformity.dto.go index 0c38d81b..af401a54 100644 --- a/internal/modules/production/uniformities/dto/uniformity.dto.go +++ b/internal/modules/production/uniformities/dto/uniformity.dto.go @@ -54,6 +54,7 @@ type UniformityDetailDTO struct { Sampling UniformitySamplingDTO `json:"sampling"` Result UniformityResultDTO `json:"result"` Standard *UniformityStandardDTO `json:"standard"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` } @@ -63,6 +64,7 @@ type UniformityListDTO struct { LocationName string `json:"location_name"` FlockName string `json:"flock_name"` KandangName string `json:"kandang_name"` + FileName string `json:"file_name"` AppliedAt *time.Time `json:"applied_at"` Week int `json:"week"` Status string `json:"status"` @@ -115,12 +117,19 @@ func ToUniformityDetailDTO( info.FileURL = documentURL } + var latestApproval *approvalDTO.ApprovalRelationDTO + if entityData.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*entityData.LatestApproval) + latestApproval = &mapped + } + return UniformityDetailDTO{ Id: entityData.Id, InfoUmum: info, Sampling: toUniformitySamplingDTO(calc), Result: toUniformityResultDTO(calc), Standard: standard, + LatestApproval: latestApproval, UniformityDetails: toUniformityDetailItemsDTO(calc), } } @@ -163,9 +172,15 @@ func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []Unifor func ToUniformityListDTOsWithStandard( items []entity.ProjectFlockKandangUniformity, standards map[uint]service.UniformityStandard, + documentNames map[uint]string, ) []UniformityListDTO { result := ToUniformityListDTOs(items) if len(result) == 0 || len(standards) == 0 { + for i := range result { + if name, ok := documentNames[result[i].Id]; ok { + result[i].FileName = name + } + } return result } @@ -174,6 +189,9 @@ func ToUniformityListDTOsWithStandard( result[i].StandardMeanWeight = std.MeanWeight result[i].StandardUniformity = std.Uniformity } + if name, ok := documentNames[result[i].Id]; ok { + result[i].FileName = name + } } return result } diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index fb7ed9ed..747eb965 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -33,6 +33,7 @@ type UniformityService interface { GetSummary(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) MapStandards(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) + MapDocuments(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) CreateOne(ctx *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) DeleteOne(ctx *fiber.Ctx, id uint) error @@ -189,6 +190,29 @@ func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFloc return result, nil } +func (s uniformityService) MapDocuments(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) { + if s.DocumentSvc == nil || len(items) == 0 { + return map[uint]string{}, nil + } + + result := make(map[uint]string, len(items)) + for _, item := range items { + if item.Id == 0 { + continue + } + documents, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(item.Id)) + if err != nil { + return nil, err + } + if len(documents) == 0 { + continue + } + result[item.Id] = documents[len(documents)-1].Name + } + + return result, nil +} + func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -649,7 +673,7 @@ func (s uniformityService) fetchUniformityDocument(ctx context.Context, uniformi return nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") } - document := documents[0] + document := documents[len(documents)-1] url, err := s.DocumentSvc.PresignURL(ctx, document, 15*time.Minute) if err != nil { return nil, "", err diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index 554b3388..410e9577 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -144,6 +144,9 @@ func (h *Controller) Refresh(c *fiber.Ctx) error { refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") refreshToken := strings.TrimSpace(c.Cookies(refreshName)) if refreshToken == "" { + if target := buildStartRedirect(defaultSSOClientAlias()); target != "" { + return c.Redirect(target, fiber.StatusFound) + } return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") } @@ -174,6 +177,9 @@ func (h *Controller) Refresh(c *fiber.Ctx) error { if resp.StatusCode == fiber.StatusTooManyRequests { return fiber.NewError(fiber.StatusTooManyRequests, "Too many attempts, please slow down") } + if target := buildStartRedirect(defaultSSOClientAlias()); target != "" { + return c.Redirect(target, fiber.StatusFound) + } return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") } @@ -425,6 +431,7 @@ func (h *Controller) Logout(c *fiber.Ctx) error { refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") var accessToken, refreshToken string + var verification *sso.VerificationResult if accessName != "" { accessToken = strings.TrimSpace(c.Cookies(accessName)) } @@ -446,9 +453,10 @@ func (h *Controller) Logout(c *fiber.Ctx) error { } if hadAccessCookie { - if verification, err := sso.VerifyAccessToken(accessToken); err != nil { + if v, err := sso.VerifyAccessToken(accessToken); err != nil { utils.Log.WithError(err).Warn("failed to verify access token during logout") } else { + verification = v if revoker := session.GetRevocationStore(); revoker != nil { if err := revoker.MarkUserLogout(c.Context(), verification.UserID, time.Now().UTC()); err != nil { utils.Log.WithError(err).Warn("failed to mark user logout") @@ -475,6 +483,28 @@ func (h *Controller) Logout(c *fiber.Ctx) error { } else if rawReturn != "" { utils.Log.WithError(err).Warn("invalid return_to during logout") } + } else if rawReturn == "" && config.SSOPortalURL != "" { + if alias, singleCfg, ok := singleClientFromToken(verification); ok { + if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" { + redirectTarget = normalized + alias, cfg, hasClientInfo = alias, singleCfg, true + } else { + redirectTarget = config.SSOPortalURL + } + } else if accessToken != "" { + if alias, singleCfg, ok := h.singleClientFromSSO(c.Context(), accessToken); ok { + if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" { + redirectTarget = normalized + alias, cfg, hasClientInfo = alias, singleCfg, true + } else { + redirectTarget = config.SSOPortalURL + } + } else { + redirectTarget = config.SSOPortalURL + } + } else { + redirectTarget = config.SSOPortalURL + } } else if rawReturn != "" { if strings.HasPrefix(rawReturn, "/") && !strings.HasPrefix(rawReturn, "//") { redirectTarget = rawReturn @@ -494,6 +524,177 @@ func (h *Controller) Logout(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "signed out"}) } +func singleSSOClient() (string, config.SSOClientConfig, bool) { + if len(config.SSOClients) != 1 { + return "", config.SSOClientConfig{}, false + } + for alias, cfg := range config.SSOClients { + if strings.TrimSpace(alias) == "" || strings.TrimSpace(cfg.PublicID) == "" { + return "", config.SSOClientConfig{}, false + } + return alias, cfg, true + } + return "", config.SSOClientConfig{}, false +} + +func singleClientFromToken(verification *sso.VerificationResult) (string, config.SSOClientConfig, bool) { + if verification == nil || verification.Claims == nil { + return "", config.SSOClientConfig{}, false + } + return singleClientFromScopes(verification.Claims.Scopes()) +} + +func (h *Controller) singleClientFromSSO(ctx context.Context, accessToken string) (string, config.SSOClientConfig, bool) { + accessToken = strings.TrimSpace(accessToken) + if accessToken == "" { + return "", config.SSOClientConfig{}, false + } + meURL := strings.TrimSpace(config.SSOGetMeURL) + if meURL == "" { + return "", config.SSOClientConfig{}, false + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil) + if err != nil { + utils.Log.WithError(err).Warn("failed to build SSO getme request") + return "", config.SSOClientConfig{}, false + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := h.httpClient.Do(req) + if err != nil { + utils.Log.WithError(err).Warn("SSO getme request failed") + return "", config.SSOClientConfig{}, false + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + utils.Log.WithField("status", resp.StatusCode).Warn("SSO getme responded with error") + return "", config.SSOClientConfig{}, false + } + + var payload struct { + Data struct { + Roles []struct { + Client *struct { + Alias string `json:"alias"` + } `json:"client"` + } `json:"roles"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + utils.Log.WithError(err).Warn("failed to decode SSO getme response") + return "", config.SSOClientConfig{}, false + } + + aliases := make(map[string]struct{}) + for _, role := range payload.Data.Roles { + if role.Client == nil { + continue + } + alias := strings.ToLower(strings.TrimSpace(role.Client.Alias)) + if alias != "" { + aliases[alias] = struct{}{} + } + } + if len(aliases) != 1 { + return "", config.SSOClientConfig{}, false + } + for alias := range aliases { + if normalized, cfg, ok := findClientAlias(alias); ok { + return normalized, cfg, true + } + return "", config.SSOClientConfig{}, false + } + return "", config.SSOClientConfig{}, false +} + +func singleClientFromScopes(scopes []string) (string, config.SSOClientConfig, bool) { + if len(scopes) == 0 { + return "", config.SSOClientConfig{}, false + } + seen := make(map[string]struct{}) + for _, scope := range scopes { + if alias, ok := matchClientAliasFromScope(scope); ok { + seen[alias] = struct{}{} + } + if len(seen) > 1 { + return "", config.SSOClientConfig{}, false + } + } + if len(seen) != 1 { + return "", config.SSOClientConfig{}, false + } + for alias := range seen { + if normalized, cfg, ok := findClientAlias(alias); ok { + return normalized, cfg, true + } + } + return "", config.SSOClientConfig{}, false +} + +func matchClientAliasFromScope(scope string) (string, bool) { + scope = strings.ToLower(strings.TrimSpace(scope)) + if scope == "" { + return "", false + } + prefix := scope + if idx := strings.IndexAny(prefix, ".:"); idx > 0 { + prefix = prefix[:idx] + } + if prefix == "" { + return "", false + } + if alias, _, ok := findClientAlias(prefix); ok { + return alias, true + } + if prefix == "user-management" { + if alias, _, ok := findClientAlias("umgmt"); ok { + return alias, true + } + } + if prefix == "umgmt" { + if alias, _, ok := findClientAlias("user-management"); ok { + return alias, true + } + } + return "", false +} + +func findClientAlias(alias string) (string, config.SSOClientConfig, bool) { + alias = strings.TrimSpace(alias) + if alias == "" { + return "", config.SSOClientConfig{}, false + } + if cfg, ok := config.SSOClients[alias]; ok && strings.TrimSpace(cfg.PublicID) != "" { + return alias, cfg, true + } + for key, cfg := range config.SSOClients { + if strings.EqualFold(key, alias) && strings.TrimSpace(cfg.PublicID) != "" { + return key, cfg, true + } + } + return "", config.SSOClientConfig{}, false +} + +func defaultSSOClientAlias() string { + for alias := range config.SSOClients { + if strings.TrimSpace(alias) == "" { + continue + } + return alias + } + return "" +} + +func buildStartRedirect(alias string) string { + alias = strings.TrimSpace(alias) + if alias == "" { + return "" + } + return "/api/sso/start?client=" + url.QueryEscape(alias) +} + func (h *Controller) revokeToken(ctx context.Context, token string, verification *sso.VerificationResult) { if h.revoker == nil || verification == nil || verification.Claims == nil { return