chore: refactor handler and utils

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
This commit is contained in:
Shivanshu Raj Shrivastava 2025-04-29 17:08:19 +05:30
parent 95bc3987bb
commit 03e50d3bc3
No known key found for this signature in database
GPG Key ID: D34D26C62AC3E9AE
2 changed files with 184 additions and 237 deletions

View File

@ -1,6 +1,7 @@
package impltracefunnel package impltracefunnel
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"time" "time"
@ -8,9 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render" "github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel" "github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/types/authtypes"
tf "github.com/SigNoz/signoz/pkg/types/tracefunnel" tf "github.com/SigNoz/signoz/pkg/types/tracefunnel"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@ -22,6 +21,25 @@ func NewHandler(module tracefunnel.Module) tracefunnel.Handler {
return &handler{module: module} return &handler{module: module}
} }
// Helper function to check for duplicate funnel names
func (handler *handler) checkDuplicateName(ctx context.Context, orgID string, name string, excludeID string) error {
funnels, err := handler.module.List(ctx, orgID)
if err != nil {
return errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"failed to list funnels: %v", err)
}
for _, f := range funnels {
if f.ID.String() != excludeID && f.Name == name {
return errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"a funnel with name '%s' already exists in this organization", name)
}
}
return nil
}
func (handler *handler) New(rw http.ResponseWriter, r *http.Request) { func (handler *handler) New(rw http.ResponseWriter, r *http.Request) {
var req tf.FunnelRequest var req tf.FunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@ -29,48 +47,26 @@ func (handler *handler) New(rw http.ResponseWriter, r *http.Request) {
return return
} }
claims, err := authtypes.ClaimsFromContext(r.Context()) claims, err := tracefunnel.GetClaims(r)
if err != nil {
render.Error(rw, err)
return
}
userID := claims.UserID
orgID := claims.OrgID
funnels, err := handler.module.List(r.Context(), orgID)
if err != nil { if err != nil {
render.Error(rw, err) render.Error(rw, err)
return return
} }
for _, f := range funnels { if err := handler.checkDuplicateName(r.Context(), claims.OrgID, req.Name, ""); err != nil {
if f.Name == req.Name { render.Error(rw, err)
render.Error(rw,
errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"a funnel with name '%s' already exists in this organization",
req.Name))
return
}
}
funnel, err := handler.module.Create(r.Context(), req.Timestamp, req.Name, userID, orgID)
if err != nil {
render.Error(rw,
errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"failed to create funnel"))
return return
} }
response := tf.FunnelResponse{ funnel, err := handler.module.Create(r.Context(), req.Timestamp, req.Name, claims.UserID, claims.OrgID)
FunnelID: funnel.ID.String(), if err != nil {
FunnelName: funnel.Name, render.Error(rw, errors.Newf(errors.TypeInvalidInput,
CreatedAt: req.Timestamp, errors.CodeInvalidInput,
UserEmail: claims.Email, "failed to create funnel"))
OrgID: orgID, return
} }
response := tracefunnel.ConstructFunnelResponse(funnel, claims)
render.Success(rw, http.StatusOK, response) render.Success(rw, http.StatusOK, response)
} }
@ -81,80 +77,42 @@ func (handler *handler) UpdateSteps(rw http.ResponseWriter, r *http.Request) {
return return
} }
claims, err := authtypes.ClaimsFromContext(r.Context()) claims, err := tracefunnel.GetClaims(r)
if err != nil { if err != nil {
render.Error(rw, err) render.Error(rw, err)
return return
} }
userID := claims.UserID
orgID := claims.OrgID
if err := tracefunnel.ValidateTimestamp(req.Timestamp, "timestamp"); err != nil { updatedAt, err := tracefunnel.ValidateAndConvertTimestamp(req.Timestamp)
render.Error(rw, if err != nil {
errors.Newf(errors.TypeInvalidInput, render.Error(rw, err)
errors.CodeInvalidInput,
"timestamp is invalid: %v", err))
return return
} }
funnel, err := handler.module.Get(r.Context(), req.FunnelID.String()) funnel, err := handler.module.Get(r.Context(), req.FunnelID.String())
if err != nil { if err != nil {
render.Error(rw, render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput,
errors.CodeInvalidInput, "funnel not found: %v", err))
"funnel not found: %v", err))
return return
} }
// Check if name is being updated and if it already exists
if req.Name != "" && req.Name != funnel.Name { if req.Name != "" && req.Name != funnel.Name {
funnels, err := handler.module.List(r.Context(), orgID) if err := handler.checkDuplicateName(r.Context(), claims.OrgID, req.Name, funnel.ID.String()); err != nil {
if err != nil { render.Error(rw, err)
render.Error(rw,
errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"failed to list funnels: %v", err))
return return
} }
for _, f := range funnels {
if f.Name == req.Name {
render.Error(rw,
errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"a funnel with name '%s' already exists in this organization", req.Name))
return
}
}
} }
// Process each step in the request steps, err := tracefunnel.ProcessFunnelSteps(req.Steps)
for i := range req.Steps { if err != nil {
if req.Steps[i].Order < 1 { render.Error(rw, err)
req.Steps[i].Order = int64(i + 1) // Default to sequential ordering if not specified
}
// Generate a new UUID for the step if it doesn't have one
if req.Steps[i].Id.IsZero() {
newUUID := valuer.GenerateUUID()
req.Steps[i].Id = newUUID
}
}
if err := tracefunnel.ValidateFunnelSteps(req.Steps); err != nil {
render.Error(rw,
errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"invalid funnel steps: %v", err))
return return
} }
// Normalize step orders funnel.Steps = steps
req.Steps = tracefunnel.NormalizeFunnelSteps(req.Steps) funnel.UpdatedAt = updatedAt
funnel.UpdatedBy = claims.UserID
// Update the funnel with new steps
funnel.Steps = req.Steps
funnel.UpdatedAt = time.Unix(0, req.Timestamp*1000000) // Convert to nanoseconds
funnel.UpdatedBy = userID
if req.Name != "" { if req.Name != "" {
funnel.Name = req.Name funnel.Name = req.Name
@ -163,39 +121,22 @@ func (handler *handler) UpdateSteps(rw http.ResponseWriter, r *http.Request) {
funnel.Description = req.Description funnel.Description = req.Description
} }
// Update funnel in database if err := handler.module.Update(r.Context(), funnel, claims.UserID); err != nil {
err = handler.module.Update(r.Context(), funnel, userID) render.Error(rw, errors.Newf(errors.TypeInvalidInput,
if err != nil { errors.CodeInvalidInput,
render.Error(rw, "failed to update funnel in database: %v", err))
errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"failed to update funnel in database: %v", err))
return return
} }
// Get the updated funnel to return in response
updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID.String()) updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID.String())
if err != nil { if err != nil {
render.Error(rw, render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput,
errors.CodeInvalidInput, "failed to get updated funnel: %v", err))
"failed to get updated funnel: %v", err))
return return
} }
response := tf.FunnelResponse{ response := tracefunnel.ConstructFunnelResponse(updatedFunnel, claims)
FunnelName: updatedFunnel.Name,
FunnelID: updatedFunnel.ID.String(),
Steps: updatedFunnel.Steps,
CreatedAt: updatedFunnel.CreatedAt.UnixNano() / 1000000,
CreatedBy: updatedFunnel.CreatedBy,
OrgID: updatedFunnel.OrgID.String(),
UpdatedBy: userID,
UpdatedAt: updatedFunnel.UpdatedAt.UnixNano() / 1000000,
Description: updatedFunnel.Description,
UserEmail: claims.Email,
}
render.Success(rw, http.StatusOK, response) render.Success(rw, http.StatusOK, response)
} }
@ -206,45 +147,38 @@ func (handler *handler) UpdateFunnel(rw http.ResponseWriter, r *http.Request) {
return return
} }
claims, err := authtypes.ClaimsFromContext(r.Context()) claims, err := tracefunnel.GetClaims(r)
if err != nil { if err != nil {
render.Error(rw, err) render.Error(rw, err)
return return
} }
userID := claims.UserID
orgID := claims.OrgID
if err := tracefunnel.ValidateTimestamp(req.Timestamp, "timestamp"); err != nil { updatedAt, err := tracefunnel.ValidateAndConvertTimestamp(req.Timestamp)
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "timestamp is invalid: %v", err)) if err != nil {
render.Error(rw, err)
return return
} }
vars := mux.Vars(r) vars := mux.Vars(r)
funnelID := vars["funnel_id"] funnelID := vars["funnel_id"]
funnel, err := handler.module.Get(r.Context(), funnelID) funnel, err := handler.module.Get(r.Context(), funnelID)
if err != nil { if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "funnel not found: %v", err)) render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"funnel not found: %v", err))
return return
} }
// Check if name is being updated and if it already exists
if req.Name != "" && req.Name != funnel.Name { if req.Name != "" && req.Name != funnel.Name {
funnels, err := handler.module.List(r.Context(), orgID) if err := handler.checkDuplicateName(r.Context(), claims.OrgID, req.Name, funnel.ID.String()); err != nil {
if err != nil { render.Error(rw, err)
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to list funnels: %v", err))
return return
} }
for _, f := range funnels {
if f.Name == req.Name {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "a funnel with name '%s' already exists in this organization", req.Name))
return
}
}
} }
funnel.UpdatedAt = time.Unix(0, req.Timestamp*1000000) // Convert to nanoseconds funnel.UpdatedAt = updatedAt
funnel.UpdatedBy = userID funnel.UpdatedBy = claims.UserID
if req.Name != "" { if req.Name != "" {
funnel.Name = req.Name funnel.Name = req.Name
@ -253,74 +187,43 @@ func (handler *handler) UpdateFunnel(rw http.ResponseWriter, r *http.Request) {
funnel.Description = req.Description funnel.Description = req.Description
} }
err = handler.module.Update(r.Context(), funnel, userID) if err := handler.module.Update(r.Context(), funnel, claims.UserID); err != nil {
if err != nil { render.Error(rw, errors.Newf(errors.TypeInvalidInput,
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to update funnel in database: %v", err)) errors.CodeInvalidInput,
"failed to update funnel in database: %v", err))
return return
} }
// Get the updated funnel to return in response
updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID.String()) updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID.String())
if err != nil { if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to get updated funnel: %v", err)) render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"failed to get updated funnel: %v", err))
return return
} }
response := tf.FunnelResponse{ response := tracefunnel.ConstructFunnelResponse(updatedFunnel, claims)
FunnelName: updatedFunnel.Name,
FunnelID: updatedFunnel.ID.String(),
Steps: updatedFunnel.Steps,
CreatedAt: updatedFunnel.CreatedAt.UnixNano() / 1000000,
CreatedBy: updatedFunnel.CreatedBy,
OrgID: updatedFunnel.OrgID.String(),
UpdatedBy: userID,
UpdatedAt: updatedFunnel.UpdatedAt.UnixNano() / 1000000,
Description: updatedFunnel.Description,
UserEmail: claims.Email,
}
render.Success(rw, http.StatusOK, response) render.Success(rw, http.StatusOK, response)
} }
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) { func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context()) claims, err := tracefunnel.GetClaims(r)
if err != nil { if err != nil {
render.Error(rw, render.Error(rw, err)
errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"unauthenticated"))
return return
} }
orgID := claims.OrgID funnels, err := handler.module.List(r.Context(), claims.OrgID)
funnels, err := handler.module.List(r.Context(), orgID)
if err != nil { if err != nil {
render.Error(rw, render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput,
errors.CodeInvalidInput, "failed to list funnels: %v", err))
"failed to list funnels: %v", err))
return return
} }
var response []tf.FunnelResponse var response []tf.FunnelResponse
for _, f := range funnels { for _, f := range funnels {
funnelResp := tf.FunnelResponse{ response = append(response, tracefunnel.ConstructFunnelResponse(f, claims))
FunnelName: f.Name,
FunnelID: f.ID.String(),
CreatedAt: f.CreatedAt.UnixNano() / 1000000,
CreatedBy: f.CreatedBy,
OrgID: f.OrgID.String(),
UpdatedAt: f.UpdatedAt.UnixNano() / 1000000,
UpdatedBy: f.UpdatedBy,
Description: f.Description,
}
// Get user email if available
if f.CreatedByUser != nil {
funnelResp.UserEmail = f.CreatedByUser.Email
}
response = append(response, funnelResp)
} }
render.Success(rw, http.StatusOK, response) render.Success(rw, http.StatusOK, response)
@ -332,29 +235,14 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
funnel, err := handler.module.Get(r.Context(), funnelID) funnel, err := handler.module.Get(r.Context(), funnelID)
if err != nil { if err != nil {
render.Error(rw, render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput,
errors.CodeInvalidInput, "funnel not found: %v", err))
"funnel not found: %v", err))
return return
} }
response := tf.FunnelResponse{ claims, _ := tracefunnel.GetClaims(r) // Ignore error as email is optional
FunnelID: funnel.ID.String(), response := tracefunnel.ConstructFunnelResponse(funnel, claims)
FunnelName: funnel.Name,
Description: funnel.Description,
CreatedAt: funnel.CreatedAt.UnixNano() / 1000000,
UpdatedAt: funnel.UpdatedAt.UnixNano() / 1000000,
CreatedBy: funnel.CreatedBy,
UpdatedBy: funnel.UpdatedBy,
OrgID: funnel.OrgID.String(),
Steps: funnel.Steps,
}
if funnel.CreatedByUser != nil {
response.UserEmail = funnel.CreatedByUser.Email
}
render.Success(rw, http.StatusOK, response) render.Success(rw, http.StatusOK, response)
} }
@ -362,12 +250,10 @@ func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
funnelID := vars["funnel_id"] funnelID := vars["funnel_id"]
err := handler.module.Delete(r.Context(), funnelID) if err := handler.module.Delete(r.Context(), funnelID); err != nil {
if err != nil { render.Error(rw, errors.Newf(errors.TypeInvalidInput,
render.Error(rw, errors.CodeInvalidInput,
errors.Newf(errors.TypeInvalidInput, "failed to delete funnel: %v", err))
errors.CodeInvalidInput,
"failed to delete funnel: %v", err))
return return
} }
@ -377,30 +263,23 @@ func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
func (handler *handler) Save(rw http.ResponseWriter, r *http.Request) { func (handler *handler) Save(rw http.ResponseWriter, r *http.Request) {
var req tf.FunnelRequest var req tf.FunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput,
errors.CodeInvalidInput, "invalid request: %v", err))
"invalid request: %v", err))
return return
} }
claims, err := authtypes.ClaimsFromContext(r.Context()) claims, err := tracefunnel.GetClaims(r)
if err != nil { if err != nil {
render.Error(rw, render.Error(rw, err)
errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"unauthenticated"))
return return
} }
orgID := claims.OrgID
usrID := claims.UserID
funnel, err := handler.module.Get(r.Context(), req.FunnelID.String()) funnel, err := handler.module.Get(r.Context(), req.FunnelID.String())
if err != nil { if err != nil {
render.Error(rw, render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput,
errors.CodeInvalidInput, "funnel not found: %v", err))
"funnel not found: %v", err))
return return
} }
@ -408,45 +287,48 @@ func (handler *handler) Save(rw http.ResponseWriter, r *http.Request) {
if updateTimestamp == 0 { if updateTimestamp == 0 {
updateTimestamp = time.Now().UnixMilli() updateTimestamp = time.Now().UnixMilli()
} else if !tracefunnel.ValidateTimestampIsMilliseconds(updateTimestamp) { } else if !tracefunnel.ValidateTimestampIsMilliseconds(updateTimestamp) {
render.Error(rw, render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput,
errors.CodeInvalidInput, "timestamp must be in milliseconds format (13 digits)"))
"timestamp must be in milliseconds format (13 digits)"))
return return
} }
funnel.UpdatedAt = time.Unix(0, updateTimestamp*1000000) // Convert to nanoseconds
if req.UserID != "" { updatedAt, err := tracefunnel.ValidateAndConvertTimestamp(updateTimestamp)
funnel.UpdatedBy = usrID if err != nil {
render.Error(rw, err)
return
} }
funnel.UpdatedAt = updatedAt
if req.UserID != "" {
funnel.UpdatedBy = claims.UserID
}
funnel.Description = req.Description funnel.Description = req.Description
if err := handler.module.Save(r.Context(), funnel, funnel.UpdatedBy, orgID); err != nil { if err := handler.module.Save(r.Context(), funnel, funnel.UpdatedBy, claims.OrgID); err != nil {
render.Error(rw, render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput,
errors.CodeInvalidInput, "failed to save funnel: %v", err))
"failed to save funnel: %v", err))
return return
} }
createdAt, updatedAt, extraDataFromDB, err := handler.module.GetFunnelMetadata(r.Context(), funnel.ID.String()) createdAtMillis, updatedAtMillis, extraDataFromDB, err := handler.module.GetFunnelMetadata(r.Context(), funnel.ID.String())
if err != nil { if err != nil {
render.Error(rw, render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput,
errors.CodeInvalidInput, "failed to get funnel metadata: %v", err))
"failed to get funnel metadata: %v", err))
return return
} }
resp := tf.FunnelResponse{ resp := tf.FunnelResponse{
FunnelName: funnel.Name, FunnelName: funnel.Name,
CreatedAt: createdAt, CreatedAt: createdAtMillis,
UpdatedAt: updatedAt, UpdatedAt: updatedAtMillis,
CreatedBy: funnel.CreatedBy, CreatedBy: funnel.CreatedBy,
UpdatedBy: funnel.UpdatedBy, UpdatedBy: funnel.UpdatedBy,
OrgID: funnel.OrgID.String(), OrgID: funnel.OrgID.String(),
Description: extraDataFromDB, Description: extraDataFromDB,
UserEmail: claims.Email,
} }
render.Success(rw, http.StatusOK, resp) render.Success(rw, http.StatusOK, resp)

View File

@ -2,8 +2,13 @@ package tracefunnel
import ( import (
"fmt" "fmt"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/authtypes"
tracefunnel "github.com/SigNoz/signoz/pkg/types/tracefunnel" tracefunnel "github.com/SigNoz/signoz/pkg/types/tracefunnel"
"github.com/SigNoz/signoz/pkg/valuer"
"net/http"
"sort" "sort"
"time"
) )
// ValidateTimestamp validates a timestamp // ValidateTimestamp validates a timestamp
@ -54,3 +59,63 @@ func NormalizeFunnelSteps(steps []tracefunnel.FunnelStep) []tracefunnel.FunnelSt
return steps return steps
} }
func GetClaims(r *http.Request) (*authtypes.Claims, error) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
return nil, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"unauthenticated")
}
return &claims, nil
}
func ValidateAndConvertTimestamp(timestamp int64) (time.Time, error) {
if err := ValidateTimestamp(timestamp, "timestamp"); err != nil {
return time.Time{}, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"timestamp is invalid: %v", err)
}
return time.Unix(0, timestamp*1000000), nil // Convert to nanoseconds
}
func ConstructFunnelResponse(funnel *tracefunnel.Funnel, claims *authtypes.Claims) tracefunnel.FunnelResponse {
resp := tracefunnel.FunnelResponse{
FunnelName: funnel.Name,
FunnelID: funnel.ID.String(),
Steps: funnel.Steps,
CreatedAt: funnel.CreatedAt.UnixNano() / 1000000,
CreatedBy: funnel.CreatedBy,
OrgID: funnel.OrgID.String(),
UpdatedBy: funnel.UpdatedBy,
UpdatedAt: funnel.UpdatedAt.UnixNano() / 1000000,
Description: funnel.Description,
}
if funnel.CreatedByUser != nil {
resp.UserEmail = funnel.CreatedByUser.Email
} else if claims != nil {
resp.UserEmail = claims.Email
}
return resp
}
func ProcessFunnelSteps(steps []tracefunnel.FunnelStep) ([]tracefunnel.FunnelStep, error) {
for i := range steps {
if steps[i].Order < 1 {
steps[i].Order = int64(i + 1)
}
if steps[i].Id.IsZero() {
steps[i].Id = valuer.GenerateUUID()
}
}
if err := ValidateFunnelSteps(steps); err != nil {
return nil, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"invalid funnel steps: %v", err)
}
return NormalizeFunnelSteps(steps), nil
}