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
279 changes: 62 additions & 217 deletions entity-service/internal/domain/entity.go

Large diffs are not rendered by default.

47 changes: 1 addition & 46 deletions entity-service/internal/handler/case_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func (h *CaseHandler) CreateCase(w http.ResponseWriter, r *http.Request) {
}

// PatchCase handles PATCH /cases/{id}.
// Accepts state, priority (both data sources), or watchList, assigneeEmail (ServiceNow only).
// Accepts stateKey, severityKey, workStateKey (both data sources), or watchList, assigneeEmail (ServiceNow only).
// Exactly one field must be provided per request.
func (h *CaseHandler) PatchCase(w http.ResponseWriter, r *http.Request) {
var req domain.UpdateCaseRequest
Expand Down Expand Up @@ -174,48 +174,3 @@ func (h *CaseHandler) GetCaseAttachmentContent(w http.ResponseWriter, r *http.Re
w.Header().Set("Content-Type", contentType)
_, _ = w.Write(content)
}

// SearchServiceRequests handles POST /service-requests/search.
func (h *CaseHandler) SearchServiceRequests(w http.ResponseWriter, r *http.Request) {
var req domain.SearchServiceRequestsRequest
if !decodeRequest(w, r, &req) {
return
}
resp, err := h.svc.SearchServiceRequests(r.Context(), req)
if err != nil {
writeServiceError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}

// SearchSecurityReportAnalysis handles POST /security-report-analyses/search.
func (h *CaseHandler) SearchSecurityReportAnalysis(w http.ResponseWriter, r *http.Request) {
var req domain.SearchSecurityReportAnalysisRequest
if !decodeRequest(w, r, &req) {
return
}
resp, err := h.svc.SearchSecurityReportAnalysis(r.Context(), req)
if err != nil {
writeServiceError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}

// SearchEngagements handles POST /engagements/search.
func (h *CaseHandler) SearchEngagements(w http.ResponseWriter, r *http.Request) {
var req domain.SearchEngagementsRequest
if !decodeRequest(w, r, &req) {
return
}
resp, err := h.svc.SearchEngagements(r.Context(), req)
if err != nil {
writeServiceError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
136 changes: 93 additions & 43 deletions entity-service/internal/repository/case_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"errors"
"fmt"
"strings"
"time"

"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
Expand All @@ -30,6 +31,7 @@ import (
"golang.org/x/sync/errgroup"
)


// CaseRepository defines the persistence operations for the cases table.
type CaseRepository interface {
// CreateCase inserts a new case row. number and internal_id are generated from
Expand Down Expand Up @@ -67,24 +69,24 @@ func (r *caseRepo) CreateCase(ctx context.Context, req domain.CreateCaseRequest)
const query = `
INSERT INTO cases (
created_by, project_id, deployment_id, deployed_product_id,
subject, description, priority, issue_type, state
subject, description, severity, issue_type, state
)
VALUES (
$1, $2, $3, $4, $5, $6,
$7::case_priority_enum, $8::case_issue_type_enum,
$7::case_severity_enum, $8::case_issue_type_enum,
'open'::case_state_enum
)
RETURNING id, number, internal_id, created_by, project_id, deployment_id, deployed_product_id,
subject, description, priority, issue_type, state, created_at, updated_at, closed_at`
subject, description, severity, issue_type, state, created_at, updated_at, closed_at`

var c domain.Case
err := r.db.QueryRow(ctx, query,
req.CreatedBy, req.ProjectID, req.DeploymentID, req.DeployedProductID,
req.Subject, req.Description, string(req.PriorityKey), string(req.IssueTypeKey),
req.Subject, req.Description, string(req.SeverityKey), string(req.IssueTypeKey),
).Scan(
&c.ID, &c.Number, &c.InternalID, &c.CreatedBy,
&c.ProjectID, &c.DeploymentID, &c.DeployedProductID,
&c.Subject, &c.Description, &c.Priority, &c.IssueType, &c.State,
&c.Subject, &c.Description, &c.Severity, &c.IssueType, &c.State,
&c.CreatedOn, &c.UpdatedOn, &c.ClosedOn,
)
if err != nil {
Expand Down Expand Up @@ -113,7 +115,7 @@ func (r *caseRepo) GetCaseByID(ctx context.Context, id string) (domain.CaseView,
)
err := r.db.QueryRow(ctx,
`SELECT c.id, c.number, c.internal_id,
c.subject, c.description, c.priority, c.issue_type, c.state, c.work_state,
c.subject, c.description, c.severity, c.issue_type, c.state, c.work_state,
c.created_at, c.updated_at, c.closed_at,
u.id, u.first_name || ' ' || u.last_name, u.user_name, u.email,
p.id, p.name,
Expand All @@ -138,7 +140,7 @@ func (r *caseRepo) GetCaseByID(ctx context.Context, id string) (domain.CaseView,
WHERE c.id = $1`, id,
).Scan(
&cv.ID, &cv.Number, &cv.InternalID,
&cv.Subject, &cv.Description, &cv.Priority, &cv.IssueType, &cv.State, &workState,
&cv.Subject, &cv.Description, &cv.Severity, &cv.IssueType, &cv.State, &workState,
&cv.CreatedOn, &cv.UpdatedOn, &cv.ClosedOn,
&cv.CreatedByDetails.ID, &cv.CreatedByDetails.Name, &cv.CreatedByDetails.UserID, &cv.CreatedByDetails.Email,
&cv.ProjectDetails.ID, &cv.ProjectDetails.Name,
Expand Down Expand Up @@ -272,9 +274,9 @@ func (r *caseRepo) UpdateCase(ctx context.Context, req domain.UpdateCaseRequest)
if req.StateKey != nil {
state = string(*req.StateKey)
}
priority := ""
if req.PriorityKey != nil {
priority = string(*req.PriorityKey)
severity := ""
if req.SeverityKey != nil {
severity = string(*req.SeverityKey)
}
workState := ""
if req.WorkStateKey != nil {
Expand All @@ -283,20 +285,20 @@ func (r *caseRepo) UpdateCase(ctx context.Context, req domain.UpdateCaseRequest)
const query = `
UPDATE cases
SET state = CASE WHEN $2 <> '' THEN $2::case_state_enum ELSE state END,
priority = CASE WHEN $3 <> '' THEN $3::case_priority_enum ELSE priority END,
severity = CASE WHEN $3 <> '' THEN $3::case_severity_enum ELSE severity END,
work_state = CASE WHEN $4 <> '' THEN $4::case_work_state_enum ELSE work_state END,
updated_at = NOW(),
closed_at = CASE WHEN $2 = 'closed' THEN NOW() WHEN $2 <> '' AND $2 <> 'closed' THEN NULL ELSE closed_at END
WHERE id = $1
RETURNING id, number, internal_id, created_by, project_id, deployment_id, deployed_product_id,
subject, description, priority, issue_type, state, work_state, created_at, updated_at, closed_at`
subject, description, severity, issue_type, state, work_state, created_at, updated_at, closed_at`

var c domain.Case
var workStateRaw *string
err := r.db.QueryRow(ctx, query, req.ID, state, priority, workState).Scan(
err := r.db.QueryRow(ctx, query, req.ID, state, severity, workState).Scan(
&c.ID, &c.Number, &c.InternalID, &c.CreatedBy,
&c.ProjectID, &c.DeploymentID, &c.DeployedProductID,
&c.Subject, &c.Description, &c.Priority, &c.IssueType, &c.State, &workStateRaw,
&c.Subject, &c.Description, &c.Severity, &c.IssueType, &c.State, &workStateRaw,
&c.CreatedOn, &c.UpdatedOn, &c.ClosedOn,
)
if errors.Is(err, pgx.ErrNoRows) {
Expand All @@ -312,13 +314,27 @@ func (r *caseRepo) UpdateCase(ctx context.Context, req domain.UpdateCaseRequest)
return c, nil
}

// pgSortColMap maps domain CaseSortField values to Postgres column expressions.
var pgSortColMap = map[domain.CaseSortField]string{
domain.CaseSortFieldCreatedOn: "c.created_at",
domain.CaseSortFieldUpdatedOn: "c.updated_at",
domain.CaseSortFieldSeverity: "c.severity",
domain.CaseSortFieldState: "c.state",
}

// SearchCases implements CaseRepository.
func (r *caseRepo) SearchCases(ctx context.Context, req domain.SearchCasesRequest) ([]domain.SearchCaseView, int, error) {
filterArgs := []any{}
argIdx := 1

where := "WHERE 1=1"

if len(req.Filters.TypeKeys) > 0 {
where += fmt.Sprintf(" AND c.type = ANY($%d::case_type_enum[])", argIdx)
filterArgs = append(filterArgs, req.Filters.TypeKeys)
argIdx++
}

if len(req.Filters.ProjectIDs) > 0 {
where += fmt.Sprintf(" AND c.project_id = ANY($%d::uuid[])", argIdx)
filterArgs = append(filterArgs, req.Filters.ProjectIDs)
Expand All @@ -331,12 +347,6 @@ func (r *caseRepo) SearchCases(ctx context.Context, req domain.SearchCasesReques
argIdx++
}

if len(req.Filters.DeployedProductIDs) > 0 {
where += fmt.Sprintf(" AND c.deployed_product_id = ANY($%d::uuid[])", argIdx)
filterArgs = append(filterArgs, req.Filters.DeployedProductIDs)
argIdx++
}

if len(req.Filters.StateKeys) > 0 {
stateStrings := make([]string, len(req.Filters.StateKeys))
for i, s := range req.Filters.StateKeys {
Expand All @@ -347,13 +357,13 @@ func (r *caseRepo) SearchCases(ctx context.Context, req domain.SearchCasesReques
argIdx++
}

if len(req.Filters.PriorityKeys) > 0 {
priorityStrings := make([]string, len(req.Filters.PriorityKeys))
for i, p := range req.Filters.PriorityKeys {
priorityStrings[i] = string(p)
if len(req.Filters.SeverityKeys) > 0 {
severityStrings := make([]string, len(req.Filters.SeverityKeys))
for i, s := range req.Filters.SeverityKeys {
severityStrings[i] = string(s)
}
where += fmt.Sprintf(" AND c.priority = ANY($%d::case_priority_enum[])", argIdx)
filterArgs = append(filterArgs, priorityStrings)
where += fmt.Sprintf(" AND c.severity = ANY($%d::case_severity_enum[])", argIdx)
filterArgs = append(filterArgs, severityStrings)
argIdx++
}

Expand All @@ -367,6 +377,16 @@ func (r *caseRepo) SearchCases(ctx context.Context, req domain.SearchCasesReques
argIdx++
}

if len(req.Filters.EngagementTypeKeys) > 0 {
engTypeStrings := make([]string, len(req.Filters.EngagementTypeKeys))
for i, et := range req.Filters.EngagementTypeKeys {
engTypeStrings[i] = string(et)
}
where += fmt.Sprintf(" AND c.engagement_type = ANY($%d::engagement_type_enum[])", argIdx)
filterArgs = append(filterArgs, engTypeStrings)
argIdx++
}

if len(req.Filters.CreatedBy) > 0 {
where += fmt.Sprintf(" AND u.email = ANY($%d)", argIdx)
filterArgs = append(filterArgs, req.Filters.CreatedBy)
Expand Down Expand Up @@ -412,26 +432,33 @@ func (r *caseRepo) SearchCases(ctx context.Context, req domain.SearchCasesReques
argIdx++
}

sortCol := "c." + string(req.SortBy.Field)
sortCol := pgSortColMap[req.SortBy.Field]
sortDir := string(req.SortBy.Order)

joins := `JOIN users u ON u.id = c.created_by
JOIN projects p ON p.id = c.project_id
JOIN deployments d ON d.id = c.deployment_id
JOIN deployed_products dp ON dp.id = c.deployed_product_id
JOIN products prod ON prod.id = dp.product_id
LEFT JOIN product_versions pv ON pv.id = dp.product_version_id`
LEFT JOIN product_versions pv ON pv.id = dp.product_version_id
LEFT JOIN users ae ON ae.id = c.assigned_engineer
LEFT JOIN cases pc ON pc.id = c.parent_case_id
LEFT JOIN cases rc ON rc.id = c.related_case_id`

countQuery := "SELECT COUNT(*) FROM cases c " + joins + " " + where

dataQuery := fmt.Sprintf(
`SELECT c.id, c.number, c.internal_id,
c.subject, c.description, c.priority, c.issue_type, c.state, c.work_state,
c.created_at, c.updated_at, c.closed_at,
u.id, u.first_name || ' ' || u.last_name, u.user_name, u.email,
c.type, c.subject, c.description, c.severity, c.issue_type, c.state,
c.engagement_type, c.work_state, c.created_at,
u.email,
p.id, p.name,
d.id, d.name,
dp.id, prod.name || COALESCE(' ' || pv.version, '')
dp.id, prod.name || COALESCE(' ' || pv.version, ''),
prod.id, prod.name,
ae.id, ae.first_name || ' ' || ae.last_name,
pc.id, pc.number,
rc.id, rc.number
FROM cases c %s %s
ORDER BY %s %s NULLS LAST, c.id
LIMIT $%d OFFSET $%d`,
Expand Down Expand Up @@ -461,22 +488,45 @@ func (r *caseRepo) SearchCases(ctx context.Context, req domain.SearchCasesReques
result := make([]domain.SearchCaseView, 0, req.Pagination.Limit)
for rows.Next() {
var cv domain.SearchCaseView
var ignoredDisplayName, ignoredUserID string
var workState *string
var caseType, subject, description string
var severity, issueType, engagementType, workState *string
var createdAt time.Time
var aeID, aeName *string
var pcID, pcNumber *string
var rcID, rcNumber *string
var prodID, prodName string
if err := rows.Scan(
&cv.ID, &cv.Number, &cv.InternalID,
&cv.Subject, &cv.Description, &cv.Priority, &cv.IssueType, &cv.State, &workState,
&cv.CreatedOn, &cv.UpdatedOn, &cv.ClosedOn,
&cv.CreatedBy.ID, &ignoredDisplayName, &ignoredUserID, &cv.CreatedBy.Email,
&cv.ProjectDetails.ID, &cv.ProjectDetails.Name,
&cv.DeploymentDetails.ID, &cv.DeploymentDetails.Name,
&cv.DeployedProductDetails.ID, &cv.DeployedProductDetails.DisplayName,
&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,
&prodID, &prodName,
&aeID, &aeName,
&pcID, &pcNumber,
&rcID, &rcNumber,
); err != nil {
return fmt.Errorf("scan case: %w", err)
}
if workState != nil {
ws := domain.CaseWorkState(*workState)
cv.WorkState = &ws
cv.CaseType = caseType
cv.Title = &subject
cv.Description = &description
cv.Severity = severity
cv.IssueType = issueType
cv.EngagementType = engagementType
cv.WorkState = workState
cv.CreatedOn = createdAt.UTC().Format(time.RFC3339)
cv.Product = &domain.EntityRef{ID: prodID, Name: prodName}
if aeID != nil {
cv.AssignedEngineer = &domain.AssignedEngineerRef{ID: *aeID, Name: *aeName}
}
if pcID != nil {
cv.ParentCase = &domain.EntityRef{ID: *pcID, Name: *pcNumber}
}
if rcID != nil {
cv.RelatedCase = &domain.EntityRef{ID: *rcID, Name: *rcNumber}
}
result = append(result, cv)
}
Expand Down
4 changes: 1 addition & 3 deletions entity-service/internal/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,7 @@ func NewRouter(db *pgxpool.Pool, cfg *config.Config) http.Handler {
mux.HandleFunc("POST /cases/{id}/attachments", caseHandler.CreateCaseAttachment)
mux.HandleFunc("POST /cases/{id}/attachments/search", caseHandler.SearchCaseAttachments)
mux.HandleFunc("GET /cases/{case_id}/attachments/{attachment_id}/content", caseHandler.GetCaseAttachmentContent)
mux.HandleFunc("POST /service-requests/search", caseHandler.SearchServiceRequests)
mux.HandleFunc("POST /security-report-analyses/search", caseHandler.SearchSecurityReportAnalysis)
mux.HandleFunc("POST /engagements/search", caseHandler.SearchEngagements)


return middleware.CorrelationID(
middleware.Recovery(
Expand Down
Loading