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
1 change: 1 addition & 0 deletions entity-service/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ All shared types live in `internal/domain/entity.go`. Conventions:
- Optional fields in request structs use pointer types (`*CasePriority`) so absent fields are distinguishable from zero values
- Response structs return the full entity row
- **Date/time field naming:** all timestamp fields in response structs must use the `On` suffix: `createdOn`, `updatedOn`, `closedOn`. Never use `At` (`createdAt`, `updatedAt`, `closedAt`). Domain-specific date fields that carry a business meaning (e.g. `startDate`, `endDate`, `activationDate`) keep the `Date` suffix. This applies to both Go struct field names and JSON tags.
- **Empty strings must never appear in responses where the value is absent.** Use pointer types (`*string`, `*EntityRef`, `*DeployedProductRef`, etc.) for any response field that may be absent, and leave them `nil` so they serialise as JSON `null`. Never assign an empty-string value to a non-pointer field as a stand-in for "not present". For optional sub-fields within a required struct (e.g. `UserRef.ID` when only the email is known), add `omitempty` to the JSON tag so they are omitted rather than serialised as `""`.
- **Request enum field naming:** enum fields in **request** structs must use the `Key` / `Keys` suffix in **both the Go struct field name and the JSON tag / openapi spec** — singular for a single value, plural for an array. Examples: Go field `StateKey \`json:"stateKey"\``, `PriorityKey \`json:"priorityKey"\``, `IssueTypeKey \`json:"issueTypeKey"\``, `WorkStateKey \`json:"workStateKey"\``, `TypeKey \`json:"typeKey"\``; arrays: `StateKeys \`json:"stateKeys"\``, `PriorityKeys \`json:"priorityKeys"\``, `IssueTypeKeys \`json:"issueTypeKeys"\``, `DeploymentTypeKeys \`json:"deploymentTypeKeys"\``. UUID ID fields follow a separate convention: `ProjectID \`json:"projectId"\`` / `ProjectIDs \`json:"projectIds"\`` (no `Key` suffix). Response structs are unaffected — they use the plain field name (e.g. `State`, `Priority` with `json:"state"`, `json:"priority"`).

## Error types (`internal/apierror`)
Expand Down
24 changes: 15 additions & 9 deletions entity-service/internal/domain/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -503,9 +503,9 @@ type Case struct {

// UserRef is a reference to a user with key display fields.
type UserRef struct {
ID string `json:"id"`
Name string `json:"name"`
UserID string `json:"userId"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
UserID string `json:"userId,omitempty"`
Email string `json:"email"`
}

Expand Down Expand Up @@ -559,14 +559,20 @@ type CaseView struct {
IssueType CaseIssueType `json:"issueType"`
State CaseState `json:"state"`
WorkState *CaseWorkState `json:"workState"`
Type *string `json:"type"`
EngagementType *string `json:"engagementType"`
CreatedOn time.Time `json:"createdOn"`
UpdatedOn time.Time `json:"updatedOn"`
ClosedOn *time.Time `json:"closedOn"`
CreatedByDetails UserRef `json:"createdBy"`
ProjectDetails EntityRef `json:"project"`
DeploymentDetails EntityRef `json:"deployment"`
DeployedProductDetails DeployedProductRef `json:"deployedProduct"`
ProductDetails EntityRef `json:"product"`
DeploymentDetails *EntityRef `json:"deployment"`
DeployedProductDetails *DeployedProductRef `json:"deployedProduct"`
ProductDetails *EntityRef `json:"product"`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Catalog *EntityRef `json:"catalog"`
CatalogItem *EntityRef `json:"catalogItem"`
AssignedTeam *EntityRef `json:"assignedTeam"`
Conversation *EntityRef `json:"conversation"`
AssignedEngineer *AssignedEngineerRef `json:"assignedEngineer"`
ParentCase *CaseNumberRef `json:"parentCase"`
RelatedCase *CaseNumberRef `json:"relatedCase"`
Expand Down Expand Up @@ -620,10 +626,10 @@ type SearchCaseView struct {
Product *EntityRef `json:"product"`
EngagementType *string `json:"engagementType"`
WorkState *string `json:"workState"`
CaseType string `json:"caseType"`
Type string `json:"type"`
Project EntityRef `json:"project"`
Deployment EntityRef `json:"deployment"`
DeployedProduct EntityRef `json:"deployedProduct"`
Deployment *EntityRef `json:"deployment"`
DeployedProduct *EntityRef `json:"deployedProduct"`
AssignedEngineer *AssignedEngineerRef `json:"assignedEngineer"`
ParentCase *EntityRef `json:"parentCase"`
RelatedCase *EntityRef `json:"relatedCase"`
Expand Down
22 changes: 16 additions & 6 deletions entity-service/internal/repository/case_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ func (r *caseRepo) GetCaseByID(ctx context.Context, id string) (domain.CaseView,
rcID, rcNum *string
accountID, accountName, accountTier string
workState *string
depID, depName string
dpID, dpDisplayName string
prodID, prodName string
)
err := r.db.QueryRow(ctx,
`SELECT c.id, c.number, c.internal_id,
Expand Down Expand Up @@ -145,9 +148,9 @@ func (r *caseRepo) GetCaseByID(ctx context.Context, id string) (domain.CaseView,
&cv.CreatedOn, &cv.UpdatedOn, &cv.ClosedOn,
&cv.CreatedByDetails.ID, &cv.CreatedByDetails.Name, &cv.CreatedByDetails.UserID, &cv.CreatedByDetails.Email,
&cv.ProjectDetails.ID, &cv.ProjectDetails.Name,
&cv.DeploymentDetails.ID, &cv.DeploymentDetails.Name,
&cv.DeployedProductDetails.ID, &cv.DeployedProductDetails.DisplayName,
&cv.ProductDetails.ID, &cv.ProductDetails.Name,
&depID, &depName,
&dpID, &dpDisplayName,
&prodID, &prodName,
&accountID, &accountName, &accountTier,
&aeID, &aeName,
&pcID, &pcNum,
Expand All @@ -159,6 +162,9 @@ func (r *caseRepo) GetCaseByID(ctx context.Context, id string) (domain.CaseView,
if err != nil {
return domain.CaseView{}, fmt.Errorf("get case by id: %w", err)
}
cv.DeploymentDetails = &domain.EntityRef{ID: depID, Name: depName}
cv.DeployedProductDetails = &domain.DeployedProductRef{ID: dpID, DisplayName: dpDisplayName}
cv.ProductDetails = &domain.EntityRef{ID: prodID, Name: prodName}
cv.AccountDetails = &domain.AccountRef{ID: accountID, Name: accountName, Type: accountTier}
if workState != nil {
ws := domain.CaseWorkState(*workState)
Expand Down Expand Up @@ -496,22 +502,26 @@ func (r *caseRepo) SearchCases(ctx context.Context, req domain.SearchCasesReques
var pcID, pcNumber *string
var rcID, rcNumber *string
var prodID, prodName string
var depID, depName string
var dpID, dpName string
if err := rows.Scan(
&cv.ID, &cv.Number, &cv.InternalID,
&caseType, &subject, &description, &severity, &issueType, &cv.State,
&engagementType, &workState, &createdAt,
&cv.CreatedBy,
&cv.Project.ID, &cv.Project.Name,
&cv.Deployment.ID, &cv.Deployment.Name,
&cv.DeployedProduct.ID, &cv.DeployedProduct.Name,
&depID, &depName,
&dpID, &dpName,
&prodID, &prodName,
&aeID, &aeName,
&pcID, &pcNumber,
&rcID, &rcNumber,
); err != nil {
return fmt.Errorf("scan case: %w", err)
}
cv.CaseType = caseType
cv.Deployment = &domain.EntityRef{ID: depID, Name: depName}
cv.DeployedProduct = &domain.EntityRef{ID: dpID, Name: dpName}
cv.Type = caseType
cv.Subject = &subject
cv.Description = &description
cv.Severity = severity
Expand Down
6 changes: 3 additions & 3 deletions entity-service/internal/service/case_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ var validCaseSortField = map[domain.CaseSortField]bool{
}

var validCaseType = map[string]bool{
"support": true,
"case": true,
"service_request": true,
"security_report_analysis": true,
"announcement": true,
Expand Down Expand Up @@ -132,8 +132,8 @@ func (s *caseService) CreateCase(ctx context.Context, req domain.CreateCaseReque
if err := validateCreateCaseRequest(req); err != nil {
return domain.CreateCaseResponse{}, err
}
if req.Type != "support" {
return domain.CreateCaseResponse{}, &apierror.ValidationError{Msg: "type must be \"support\" for case creation"}
if req.Type != "case" {
return domain.CreateCaseResponse{}, &apierror.ValidationError{Msg: "type must be \"case\" for case creation"}
}
if err := validateUUIDs("projectId", []string{req.ProjectID}); err != nil {
return domain.CreateCaseResponse{}, err
Expand Down
126 changes: 95 additions & 31 deletions entity-service/internal/service/sn_case_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,35 @@ type snCaseSort struct {

// snCaseTypeMap maps domain case type strings to the ServiceNow caseType values.
var snCaseTypeMap = map[string]string{
"support": "default_case",
"case": "default_case",
"service_request": "service_request",
"security_report_analysis": "security_report_analysis",
"announcement": "announcement",
"engagement": "engagement",
}

// snCaseTypeSysidMap maps ServiceNow caseType sysids to domain case type values.
var snCaseTypeSysidMap = map[string]string{
"8d4b87bd1b18f010cb6898aebd4bcb59": "case",
"0d5b8fbd1b18f010cb6898aebd4bcba5": "case",
"5aeff1201b74c210264c997a234bcb54": "service_request",
"ab36479047ccf510a0a29cd3846d43ee": "security_report_analysis",
"3b8b43311b58f010cb6898aebd4bcb8f": "announcement",
"8f8fc2c41b0bd550d64e64a2604bcb38": "announcement",
}

// snCaseTypeToDomain converts a SN caseType entity ref to the domain type string.
// Maps by sysid; defaults to "case" when caseType is null or the sysid is unrecognised.
func snCaseTypeToDomain(ct *snCaseEntityRef) *string {
domainType := "case"
if ct != nil {
if mapped, ok := snCaseTypeSysidMap[ct.ID]; ok {
domainType = mapped
}
}
return &domainType
Comment thread
cloby99 marked this conversation as resolved.
}

func domainTypeKeysToSN(typeKeys []string) []string {
result := make([]string, 0, len(typeKeys))
for _, t := range typeKeys {
Expand Down Expand Up @@ -314,8 +336,8 @@ func (s *snCaseService) CreateCase(ctx context.Context, req domain.CreateCaseReq
return domain.CreateCaseResponse{}, &apierror.UnauthorizedError{Msg: "x-user-id-token header is required"}
}

if req.Type != "support" {
return domain.CreateCaseResponse{}, &apierror.ValidationError{Msg: "typeKey must be \"support\" for case creation"}
if req.Type != "case" {
return domain.CreateCaseResponse{}, &apierror.ValidationError{Msg: "type must be \"case\" for case creation"}
}
snType := snCaseTypeMap[req.Type]

Expand Down Expand Up @@ -397,30 +419,58 @@ func (s *snCaseService) GetCaseByID(ctx context.Context, id string) (domain.Case
}

cv := domain.CaseView{
ID: sysidToUUID(c.ID),
Number: c.Number,
InternalID: c.InternalID,
Subject: c.Title,
Description: c.Description,
Severity: snSeverityToSeverity(c.Severity),
IssueType: snIssueTypeToEnum(c.IssueType),
State: state,
WorkState: snWorkStateLabelToEnum(c.WorkState),
CreatedOn: createdOn,
UpdatedOn: updatedOn,
ID: sysidToUUID(c.ID),
Number: c.Number,
InternalID: c.InternalID,
Subject: c.Title,
Description: c.Description,
Severity: snSeverityToSeverity(c.Severity),
IssueType: snIssueTypeToEnum(c.IssueType),
State: state,
WorkState: snWorkStateLabelToEnum(c.WorkState),
Type: snCaseTypeToDomain(c.CaseType),
EngagementType: snLabelStr(c.EngagementType),
CreatedOn: createdOn,
UpdatedOn: updatedOn,
CreatedByDetails: domain.UserRef{
Email: c.CreatedBy,
},
ProjectDetails: domain.EntityRef{ID: sysidToUUID(c.Project.ID), Name: c.Project.Name},
DeploymentDetails: domain.EntityRef{ID: sysidToUUID(c.Deployment.ID), Name: c.Deployment.Name},
DeployedProductDetails: domain.DeployedProductRef{
ID: sysidToUUID(c.DeployedProduct.ID),
DisplayName: strings.TrimSpace(c.DeployedProduct.Name + " " + c.DeployedProduct.Version),
},
}

if depID := sysidToUUID(c.Deployment.ID); depID != "" {
cv.DeploymentDetails = &domain.EntityRef{ID: depID, Name: c.Deployment.Name}
}
if dpID := sysidToUUID(c.DeployedProduct.ID); dpID != "" {
cv.DeployedProductDetails = &domain.DeployedProductRef{
ID: dpID,
DisplayName: strings.TrimSpace(c.DeployedProduct.Name + " " + c.DeployedProduct.Version),
}
}
if c.Product != nil {
cv.ProductDetails = domain.EntityRef{ID: sysidToUUID(c.Product.ID), Name: c.Product.Name}
if id := sysidToUUID(c.Product.ID); id != "" {
cv.ProductDetails = &domain.EntityRef{ID: id, Name: c.Product.Name}
}
}
if c.Catalog != nil {
if id := sysidToUUID(c.Catalog.ID); id != "" {
cv.Catalog = &domain.EntityRef{ID: id, Name: c.Catalog.Name}
}
}
if c.CatalogItem != nil {
if id := sysidToUUID(c.CatalogItem.ID); id != "" {
cv.CatalogItem = &domain.EntityRef{ID: id, Name: c.CatalogItem.Name}
}
}
if c.AssignedTeam != nil {
if id := sysidToUUID(c.AssignedTeam.ID); id != "" {
cv.AssignedTeam = &domain.EntityRef{ID: id, Name: c.AssignedTeam.Name}
}
}
if c.Conversation != nil {
if id := sysidToUUID(c.Conversation.ID); id != "" {
cv.Conversation = &domain.EntityRef{ID: id, Name: c.Conversation.Name}
}
}
if c.AssignedEngineer != nil {
cv.AssignedEngineer = &domain.AssignedEngineerRef{ID: sysidToUUID(c.AssignedEngineer.ID), Name: c.AssignedEngineer.Name, Email: c.AssignedEngineer.Email}
Expand Down Expand Up @@ -638,6 +688,7 @@ type snUpdateCaseResponse struct {
UpdatedBy string `json:"updatedBy"`
State *snCaseState `json:"state"`
Severity *snCaseLabel `json:"severity"`
WorkState *snCaseLabel `json:"workState"`
WatchList []struct {
ID string `json:"id"`
UserName string `json:"userName"`
Expand Down Expand Up @@ -755,6 +806,7 @@ func (s *snCaseService) UpdateCase(ctx context.Context, req domain.UpdateCaseReq
if snResp.Case.Severity != nil {
resp.Case.Severity = snSeverityToSeverity(snResp.Case.Severity)
}
resp.Case.WorkState = snWorkStateLabelToEnum(snResp.Case.WorkState)
if snResp.Case.AssignedTo != nil {
resp.Case.AssignedTo = &domain.AssignedEngineerRef{
ID: sysidToUUID(snResp.Case.AssignedTo.ID),
Expand Down Expand Up @@ -1072,9 +1124,9 @@ func (s *snCaseService) SearchCases(ctx context.Context, req domain.SearchCasesR
if c.State != nil {
stateLabel = c.State.Label
}
caseType := ""
if c.CaseType != nil {
caseType = c.CaseType.Name
caseTypeDomain := ""
if t := snCaseTypeToDomain(c.CaseType); t != nil {
caseTypeDomain = *t
}

cv := domain.SearchCaseView{
Expand All @@ -1090,22 +1142,34 @@ func (s *snCaseService) SearchCases(ctx context.Context, req domain.SearchCasesR
Severity: severityLabel,
EngagementType: engagementTypeLabel,
WorkState: workStateLabel,
CaseType: caseType,
Project: domain.EntityRef{ID: sysidToUUID(c.Project.ID), Name: c.Project.Name},
Deployment: domain.EntityRef{ID: sysidToUUID(c.Deployment.ID), Name: c.Deployment.Name},
DeployedProduct: domain.EntityRef{ID: sysidToUUID(c.DeployedProduct.ID), Name: strings.TrimSpace(c.DeployedProduct.Name + " " + c.DeployedProduct.Version)},
Type: caseTypeDomain,
Project: domain.EntityRef{ID: sysidToUUID(c.Project.ID), Name: c.Project.Name},
}
if depID := sysidToUUID(c.Deployment.ID); depID != "" {
cv.Deployment = &domain.EntityRef{ID: depID, Name: c.Deployment.Name}
}
if dpID := sysidToUUID(c.DeployedProduct.ID); dpID != "" {
cv.DeployedProduct = &domain.EntityRef{ID: dpID, Name: strings.TrimSpace(c.DeployedProduct.Name + " " + c.DeployedProduct.Version)}
}
if c.Product != nil {
cv.Product = &domain.EntityRef{ID: sysidToUUID(c.Product.ID), Name: c.Product.Name}
if id := sysidToUUID(c.Product.ID); id != "" {
cv.Product = &domain.EntityRef{ID: id, Name: c.Product.Name}
}
}
if c.Catalog != nil {
cv.Catalog = &domain.EntityRef{ID: sysidToUUID(c.Catalog.ID), Name: c.Catalog.Name}
if id := sysidToUUID(c.Catalog.ID); id != "" {
cv.Catalog = &domain.EntityRef{ID: id, Name: c.Catalog.Name}
}
}
if c.CatalogItem != nil {
cv.CatalogItem = &domain.EntityRef{ID: sysidToUUID(c.CatalogItem.ID), Name: c.CatalogItem.Name}
if id := sysidToUUID(c.CatalogItem.ID); id != "" {
cv.CatalogItem = &domain.EntityRef{ID: id, Name: c.CatalogItem.Name}
}
}
if c.AssignedTeam != nil {
cv.AssignedTeam = &domain.EntityRef{ID: sysidToUUID(c.AssignedTeam.ID), Name: c.AssignedTeam.Name}
if id := sysidToUUID(c.AssignedTeam.ID); id != "" {
cv.AssignedTeam = &domain.EntityRef{ID: id, Name: c.AssignedTeam.Name}
}
}
if c.Conversation != nil {
cv.Conversation = &domain.EntityRef{ID: sysidToUUID(c.Conversation.ID), Name: c.Conversation.Name}
Expand Down
10 changes: 5 additions & 5 deletions entity-service/migrations/000008_create_cases.up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ CREATE TYPE case_work_state_enum AS ENUM (
);

CREATE TYPE case_type_enum AS ENUM (
'support',
'case',
'service_request',
'security_report_analysis',
'announcement',
Expand Down Expand Up @@ -57,7 +57,7 @@ CREATE TABLE cases (
project_id UUID NOT NULL REFERENCES projects(id),
deployment_id UUID NOT NULL REFERENCES deployments(id),
deployed_product_id UUID NOT NULL REFERENCES deployed_products(id),
type case_type_enum NOT NULL DEFAULT 'support',
type case_type_enum NOT NULL DEFAULT 'case',
subject VARCHAR NOT NULL,
description TEXT NOT NULL,
severity case_severity_enum NULL,
Expand Down Expand Up @@ -85,11 +85,11 @@ CREATE TABLE cases (
(state != 'work_in_progress' AND work_state IS NULL)
),

CONSTRAINT chk_severity_required_for_support
CONSTRAINT chk_severity_required_for_case
CHECK (
(type = 'support' AND severity IS NOT NULL)
(type = 'case' AND severity IS NOT NULL)
OR
(type != 'support' AND severity IS NULL)
(type != 'case' AND severity IS NULL)
),

CONSTRAINT chk_engagement_type_only_on_engagement
Expand Down
Loading