Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/csm-portal/backend/internal/handler/deployments.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func (h *DeploymentHandler) PostDeployment(w http.ResponseWriter, r *http.Reques
}

// PatchDeployment handles PATCH /deployments/{id}.
// Accepts name, typeKey, description (detail fields) or active=false (deactivation) and forwards to the entity service.
// Accepts name, type, description (detail fields) or active=false (deactivation) and forwards to the entity service.
func (h *DeploymentHandler) PatchDeployment(w http.ResponseWriter, r *http.Request) {
user := middleware.UserInfoFromContext(r.Context())
if user == nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import (
)

func TestPostDeployment(t *testing.T) {
const validBody = `{"projectId":"11111111-1111-1111-1111-111111111111","name":"Prod","typeKey":1,"description":"Main prod deployment"}`
const validBody = `{"projectId":"11111111-1111-1111-1111-111111111111","name":"Prod","type":"primary_production","description":"Main prod deployment"}`

t.Run("requires authenticated user", func(t *testing.T) {
h := NewDeploymentHandler(&mockEntityDeploymentClient{})
Expand Down
20 changes: 11 additions & 9 deletions apps/csm-portal/backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -927,7 +927,7 @@ paths:
type: string
format: uuid
requestBody:
description: Deployment update payload. Provide either detail fields (name, typeKey, description) or active=false, but not both.
description: Deployment update payload. Provide either detail fields (name, type, description) or active=false, but not both.
required: true
content:
application/json:
Expand Down Expand Up @@ -2299,7 +2299,7 @@ components:

DeploymentCreatePayload:
type: object
required: [projectId, name, typeKey, description]
required: [projectId, name, type, description]
properties:
projectId:
type: string
Expand All @@ -2308,9 +2308,10 @@ components:
name:
type: string
description: Display name of the deployment.
typeKey:
type: integer
description: ServiceNow deployment type integer key.
type:
type: string
enum: [primary_production, staging, qa, stress, uat, development]
description: Deployment environment type.
description:
type: string
description: Description of the deployment.
Expand All @@ -2334,15 +2335,16 @@ components:

DeploymentUpdatePayload:
oneOf:
- description: Update deployment detail fields. At least one of name, typeKey, or description must be provided.
- description: Update deployment detail fields. At least one of name, type, or description must be provided.
type: object
minProperties: 1
properties:
name:
type: string
typeKey:
type: integer
description: ServiceNow deployment type integer key.
type:
type: string
enum: [primary_production, staging, qa, stress, uat, development]
description: Deployment environment type.
description:
type: string
nullable: true
Expand Down
5 changes: 5 additions & 0 deletions entity-service/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ ServiceNow uses 32-character hex sysids (e.g. `abc123...`) while the rest of the

Missing a `sysidToUUID()` call on a response ID means callers receive a bare sysid they cannot use to call back into the entity service.

**SN payload field types must match what the Choreo Ballerina integration service expects.** The public domain API and the `sn_*` payload structs are separate layers with different representations:

- **String enum → integer key:** ServiceNow choice-list fields use integer keys (`typeKey`, `stateKey`, etc.) in the Choreo API even when the domain exposes string enums (e.g. `"primary_production"`). Add a `xxxToKey map[domain.XxxType]int` in the SN service file (see `deploymentTypeToKey` in `sn_deployment_service.go`) and look up the integer before populating the SN payload. Never pass a string directly into a field the Choreo API defines as an integer — it will fail at runtime with a Ballerina data-binding error.
- **Before adding a new writable SN endpoint**, read the existing `sn_*` payload structs for that entity (or a similar one) to confirm which fields Choreo expects as integers vs strings. Cross-reference the Choreo API contract to identify which choice-list fields require integer keys.

## Security

- Never commit secrets — use environment variables; `.env` is git-ignored
Expand Down
23 changes: 11 additions & 12 deletions entity-service/internal/domain/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,13 +351,12 @@ type SearchDeploymentsResponse struct {
}

// CreateDeploymentRequest is the input for POST /deployments.
// All four fields are required. TypeKey uses a pointer to distinguish an omitted
// field from an explicit value (including 0).
// All four fields are required. Type uses a pointer to distinguish an omitted field from a zero value.
type CreateDeploymentRequest struct {
ProjectID string `json:"projectId"`
Name string `json:"name"`
TypeKey *int `json:"typeKey"`
Description string `json:"description"`
ProjectID string `json:"projectId"`
Name string `json:"name"`
Type *DeploymentType `json:"type"`
Description string `json:"description"`
}

// CreateDeploymentResponse is the response for POST /deployments.
Expand All @@ -374,18 +373,18 @@ type CreatedDeployment struct {
}

// UpdateDeploymentRequest is the input for PATCH /deployments/{id}.
// Either detail fields (Name, TypeKey, Description) or Active (to deactivate) must be provided,
// Either detail fields (Name, Type, Description) or Active (to deactivate) must be provided,
// but not both groups in the same request. Active can only be set to false.
// Description uses a pointer-to-pointer to distinguish three states:
// - nil outer pointer: field omitted — leave description unchanged
// - non-nil outer, nil inner (*Description == nil): explicit null — clear the description
// - non-nil outer, non-nil inner: set description to the given value
type UpdateDeploymentRequest struct {
ID string `json:"-"`
Name *string `json:"name"`
TypeKey *int `json:"typeKey"`
Description **string `json:"description"`
Active *bool `json:"active"`
ID string `json:"-"`
Name *string `json:"name"`
Type *DeploymentType `json:"type"`
Description **string `json:"description"`
Active *bool `json:"active"`
}

// UpdateDeploymentResponse is the response for PATCH /deployments/{id}.
Expand Down
37 changes: 29 additions & 8 deletions entity-service/internal/service/sn_deployment_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,11 @@ func (s *snDeploymentService) CreateDeployment(ctx context.Context, req domain.C
if req.Name == "" {
return domain.CreateDeploymentResponse{}, &apierror.ValidationError{Msg: "name is required"}
}
if req.TypeKey == nil {
return domain.CreateDeploymentResponse{}, &apierror.ValidationError{Msg: "typeKey is required"}
if req.Type == nil {
return domain.CreateDeploymentResponse{}, &apierror.ValidationError{Msg: "type is required"}
}
if _, ok := validDeploymentTypes[*req.Type]; !ok {
return domain.CreateDeploymentResponse{}, &apierror.ValidationError{Msg: fmt.Sprintf("invalid type %q", *req.Type)}
}
if req.Description == "" {
return domain.CreateDeploymentResponse{}, &apierror.ValidationError{Msg: "description is required"}
Expand All @@ -185,7 +188,7 @@ func (s *snDeploymentService) CreateDeployment(ctx context.Context, req domain.C
payload := snCreateDeploymentPayload{
ProjectID: uuidToSysid(req.ProjectID),
Name: req.Name,
TypeKey: *req.TypeKey,
TypeKey: deploymentTypeToKey[*req.Type],
Description: req.Description,
}

Expand Down Expand Up @@ -239,13 +242,18 @@ func (s *snDeploymentService) UpdateDeployment(ctx context.Context, req domain.U
return domain.UpdateDeploymentResponse{}, err
}

hasDetailFields := req.Name != nil || req.TypeKey != nil || req.Description != nil
hasDetailFields := req.Name != nil || req.Type != nil || req.Description != nil
if !hasDetailFields && req.Active == nil {
return domain.UpdateDeploymentResponse{}, &apierror.ValidationError{Msg: "at least one of name, typeKey, description, or active must be provided"}
return domain.UpdateDeploymentResponse{}, &apierror.ValidationError{Msg: "at least one of name, type, description, or active must be provided"}
}
if hasDetailFields && req.Active != nil {
return domain.UpdateDeploymentResponse{}, &apierror.ValidationError{Msg: "active must not be provided when updating deployment details"}
}
if req.Type != nil {
if _, ok := validDeploymentTypes[*req.Type]; !ok {
return domain.UpdateDeploymentResponse{}, &apierror.ValidationError{Msg: fmt.Sprintf("invalid type %q", *req.Type)}
}
}
if req.Active != nil && *req.Active {
return domain.UpdateDeploymentResponse{}, &apierror.ValidationError{Msg: "active can only be set to false"}
}
Expand All @@ -256,9 +264,12 @@ func (s *snDeploymentService) UpdateDeployment(ctx context.Context, req domain.U
}

payload := snUpdateDeploymentPayload{
Name: req.Name,
TypeKey: req.TypeKey,
Active: req.Active,
Name: req.Name,
Active: req.Active,
}
if req.Type != nil {
k := deploymentTypeToKey[*req.Type]
payload.TypeKey = &k
}
if req.Description != nil {
if *req.Description == nil {
Expand Down Expand Up @@ -307,6 +318,16 @@ var validDeploymentTypes = map[domain.DeploymentType]struct{}{
domain.DeploymentTypeDevelopment: {},
}

// deploymentTypeToKey maps the domain DeploymentType string to the ServiceNow integer choice-list key.
var deploymentTypeToKey = map[domain.DeploymentType]int{
domain.DeploymentTypeDevelopment: 1,
domain.DeploymentTypeQA: 2,
domain.DeploymentTypeStaging: 3,
domain.DeploymentTypeStress: 4,
domain.DeploymentTypeUAT: 5,
domain.DeploymentTypePrimaryProduction: 6,
}

// snDeployTypeLabelToEnum converts a SN deployment type label (e.g. "Primary Production")
// to the domain DeploymentType enum (e.g. "primary_production").
func snDeployTypeLabelToEnum(label string) (domain.DeploymentType, error) {
Expand Down
18 changes: 10 additions & 8 deletions entity-service/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1314,7 +1314,7 @@ components:

CreateDeploymentRequest:
type: object
required: [projectId, name, typeKey, description]
required: [projectId, name, type, description]
properties:
projectId:
type: string
Expand All @@ -1323,9 +1323,10 @@ components:
name:
type: string
description: Display name of the deployment.
typeKey:
type: integer
description: ServiceNow deployment type integer key.
type:
type: string
enum: [primary_production, staging, qa, stress, uat, development]
description: Deployment environment type.
description:
type: string
description: Description of the deployment.
Expand All @@ -1352,15 +1353,16 @@ components:

UpdateDeploymentRequest:
oneOf:
- description: Update deployment detail fields. At least one of name, typeKey, or description must be provided.
- description: Update deployment detail fields. At least one of name, type, or description must be provided.
type: object
minProperties: 1
properties:
name:
type: string
typeKey:
type: integer
description: ServiceNow deployment type integer key.
type:
type: string
enum: [primary_production, staging, qa, stress, uat, development]
description: Deployment environment type.
description:
type: string
nullable: true
Expand Down