feat(BE-281): adjustment sso redirect,adjustment response closing,adjustment uniformity

This commit is contained in:
ragilap
2026-01-06 12:02:19 +07:00
parent c1a162b4d4
commit 1bdaf63763
8 changed files with 267 additions and 34 deletions
+2
View File
@@ -54,6 +54,7 @@ var (
SSOAuthorizeURL string SSOAuthorizeURL string
SSOTokenURL string SSOTokenURL string
SSOGetMeURL string SSOGetMeURL string
SSOPortalURL string
SSOClients map[string]SSOClientConfig SSOClients map[string]SSOClientConfig
SSOAccessCookieName string SSOAccessCookieName string
SSORefreshCookieName string SSORefreshCookieName string
@@ -131,6 +132,7 @@ func init() {
SSOAuthorizeURL = viper.GetString("SSO_AUTHORIZE_URL") SSOAuthorizeURL = viper.GetString("SSO_AUTHORIZE_URL")
SSOTokenURL = viper.GetString("SSO_TOKEN_URL") SSOTokenURL = viper.GetString("SSO_TOKEN_URL")
SSOGetMeURL = viper.GetString("SSO_GETME_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") SSOAccessCookieName = defaultString(viper.GetString("SSO_ACCESS_COOKIE_NAME"), "sso_access")
SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh") SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh")
SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN") SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN")
@@ -134,7 +134,14 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
report = &SapronakReportDTO{} 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{} byFlag := map[string]**SapronakCategoryDTO{}
if filter == "" || filter == "DOC" { if filter == "" || filter == "DOC" {
@@ -149,10 +156,6 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
result.Pakan = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)} result.Pakan = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)}
byFlag["PAKAN"] = &result.Pakan 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 { formatDate := func(t *time.Time) string {
if t == nil { if t == nil {
@@ -162,7 +165,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
} }
for _, group := range report.Groups { for _, group := range report.Groups {
flagKey := strings.ToUpper(group.Flag) flagKey := normalizeFlag(group.Flag)
ptr := byFlag[flagKey] ptr := byFlag[flagKey]
if ptr == nil || *ptr == nil { if ptr == nil || *ptr == nil {
continue continue
@@ -182,7 +185,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
} }
for idx, item := range group.Items { for idx, item := range group.Items {
productKey := strings.ToUpper(group.Flag + "|" + item.ProductName) productKey := strings.ToUpper(flagKey + "|" + item.ProductName)
baseRow := SapronakCategoryRowDTO{ baseRow := SapronakCategoryRowDTO{
ID: idx + 1, ID: idx + 1,
Date: formatDate(item.Tanggal), Date: formatDate(item.Tanggal),
@@ -246,7 +249,5 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
buildTotals(result.Doc, "TOTAL DOC") buildTotals(result.Doc, "TOTAL DOC")
buildTotals(result.Ovk, "TOTAL OVK") buildTotals(result.Ovk, "TOTAL OVK")
buildTotals(result.Pakan, "TOTAL PAKAN") buildTotals(result.Pakan, "TOTAL PAKAN")
buildTotals(result.Pullet, "TOTAL PULLET")
return result return result
} }
@@ -359,7 +359,11 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if filterFlag == "" { if filterFlag == "" {
return true 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 // 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 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) { func (s projectflockService) GetProjectPeriods(c *fiber.Ctx, projectIDs []uint) (map[uint]int, error) {
if len(projectIDs) == 0 { if len(projectIDs) == 0 {
return map[uint]int{}, nil return map[uint]int{}, nil
@@ -36,6 +36,10 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error {
if err != nil { if err != nil {
return err return err
} }
documents, err := u.UniformityService.MapDocuments(c, result)
if err != nil {
return err
}
return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{ JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{
@@ -53,7 +57,7 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error {
"status": "Pengajuan", "status": "Pengajuan",
}, },
}, },
Data: dto.ToUniformityListDTOsWithStandard(result, standards), Data: dto.ToUniformityListDTOsWithStandard(result, standards, documents),
}) })
} }
@@ -54,6 +54,7 @@ type UniformityDetailDTO struct {
Sampling UniformitySamplingDTO `json:"sampling"` Sampling UniformitySamplingDTO `json:"sampling"`
Result UniformityResultDTO `json:"result"` Result UniformityResultDTO `json:"result"`
Standard *UniformityStandardDTO `json:"standard"` Standard *UniformityStandardDTO `json:"standard"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"`
} }
@@ -63,6 +64,7 @@ type UniformityListDTO struct {
LocationName string `json:"location_name"` LocationName string `json:"location_name"`
FlockName string `json:"flock_name"` FlockName string `json:"flock_name"`
KandangName string `json:"kandang_name"` KandangName string `json:"kandang_name"`
FileName string `json:"file_name"`
AppliedAt *time.Time `json:"applied_at"` AppliedAt *time.Time `json:"applied_at"`
Week int `json:"week"` Week int `json:"week"`
Status string `json:"status"` Status string `json:"status"`
@@ -115,12 +117,19 @@ func ToUniformityDetailDTO(
info.FileURL = documentURL info.FileURL = documentURL
} }
var latestApproval *approvalDTO.ApprovalRelationDTO
if entityData.LatestApproval != nil {
mapped := approvalDTO.ToApprovalDTO(*entityData.LatestApproval)
latestApproval = &mapped
}
return UniformityDetailDTO{ return UniformityDetailDTO{
Id: entityData.Id, Id: entityData.Id,
InfoUmum: info, InfoUmum: info,
Sampling: toUniformitySamplingDTO(calc), Sampling: toUniformitySamplingDTO(calc),
Result: toUniformityResultDTO(calc), Result: toUniformityResultDTO(calc),
Standard: standard, Standard: standard,
LatestApproval: latestApproval,
UniformityDetails: toUniformityDetailItemsDTO(calc), UniformityDetails: toUniformityDetailItemsDTO(calc),
} }
} }
@@ -163,9 +172,15 @@ func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []Unifor
func ToUniformityListDTOsWithStandard( func ToUniformityListDTOsWithStandard(
items []entity.ProjectFlockKandangUniformity, items []entity.ProjectFlockKandangUniformity,
standards map[uint]service.UniformityStandard, standards map[uint]service.UniformityStandard,
documentNames map[uint]string,
) []UniformityListDTO { ) []UniformityListDTO {
result := ToUniformityListDTOs(items) result := ToUniformityListDTOs(items)
if len(result) == 0 || len(standards) == 0 { 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 return result
} }
@@ -174,6 +189,9 @@ func ToUniformityListDTOsWithStandard(
result[i].StandardMeanWeight = std.MeanWeight result[i].StandardMeanWeight = std.MeanWeight
result[i].StandardUniformity = std.Uniformity result[i].StandardUniformity = std.Uniformity
} }
if name, ok := documentNames[result[i].Id]; ok {
result[i].FileName = name
}
} }
return result return result
} }
@@ -33,6 +33,7 @@ type UniformityService interface {
GetSummary(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) GetSummary(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error)
GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error)
MapStandards(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]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) 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) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error)
DeleteOne(ctx *fiber.Ctx, id uint) 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 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) { 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 { if err := s.Validate.Struct(req); err != nil {
return nil, err 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") 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) url, err := s.DocumentSvc.PresignURL(ctx, document, 15*time.Minute)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
@@ -144,6 +144,9 @@ func (h *Controller) Refresh(c *fiber.Ctx) error {
refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh")
refreshToken := strings.TrimSpace(c.Cookies(refreshName)) refreshToken := strings.TrimSpace(c.Cookies(refreshName))
if refreshToken == "" { if refreshToken == "" {
if target := buildStartRedirect(defaultSSOClientAlias()); target != "" {
return c.Redirect(target, fiber.StatusFound)
}
return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated")
} }
@@ -174,6 +177,9 @@ func (h *Controller) Refresh(c *fiber.Ctx) error {
if resp.StatusCode == fiber.StatusTooManyRequests { if resp.StatusCode == fiber.StatusTooManyRequests {
return fiber.NewError(fiber.StatusTooManyRequests, "Too many attempts, please slow down") 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") return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated")
} }
@@ -425,6 +431,7 @@ func (h *Controller) Logout(c *fiber.Ctx) error {
refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh")
var accessToken, refreshToken string var accessToken, refreshToken string
var verification *sso.VerificationResult
if accessName != "" { if accessName != "" {
accessToken = strings.TrimSpace(c.Cookies(accessName)) accessToken = strings.TrimSpace(c.Cookies(accessName))
} }
@@ -446,9 +453,10 @@ func (h *Controller) Logout(c *fiber.Ctx) error {
} }
if hadAccessCookie { 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") utils.Log.WithError(err).Warn("failed to verify access token during logout")
} else { } else {
verification = v
if revoker := session.GetRevocationStore(); revoker != nil { if revoker := session.GetRevocationStore(); revoker != nil {
if err := revoker.MarkUserLogout(c.Context(), verification.UserID, time.Now().UTC()); err != nil { if err := revoker.MarkUserLogout(c.Context(), verification.UserID, time.Now().UTC()); err != nil {
utils.Log.WithError(err).Warn("failed to mark user logout") 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 != "" { } else if rawReturn != "" {
utils.Log.WithError(err).Warn("invalid return_to during logout") 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 != "" { } else if rawReturn != "" {
if strings.HasPrefix(rawReturn, "/") && !strings.HasPrefix(rawReturn, "//") { if strings.HasPrefix(rawReturn, "/") && !strings.HasPrefix(rawReturn, "//") {
redirectTarget = 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"}) 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) { func (h *Controller) revokeToken(ctx context.Context, token string, verification *sso.VerificationResult) {
if h.revoker == nil || verification == nil || verification.Claims == nil { if h.revoker == nil || verification == nil || verification.Claims == nil {
return return