Merge branch 'feat/BE/US-281-uniformity' into 'development'

[FIX/BE-281] adjustment sso redirect,adjustment response closing,adjustment uniformity

See merge request mbugroup/lti-api!132
This commit is contained in:
Hafizh A. Y.
2026-01-06 10:08:24 +00:00
8 changed files with 267 additions and 34 deletions
+2
View File
@@ -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")
@@ -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
}
@@ -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
@@ -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
@@ -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),
})
}
@@ -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
}
@@ -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
@@ -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