Skip to content
Merged
104 changes: 104 additions & 0 deletions entity-service/internal/domain/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,3 +311,107 @@ type SearchDeployedProductsResponse struct {
Offset int `json:"offset"`
HasMore bool `json:"hasMore"`
}

// CaseIssueType classifies the nature of a support case.
type CaseIssueType string

const (
CaseIssueTypeError CaseIssueType = "error"
CaseIssueTypePartialOutage CaseIssueType = "partial_outage"
CaseIssueTypePerformanceDegradation CaseIssueType = "performance_degradation"
CaseIssueTypeQuestion CaseIssueType = "question"
CaseIssueTypeSecurityOrCompliance CaseIssueType = "security_or_compliance"
CaseIssueTypeTotalOutage CaseIssueType = "total_outage"
)

// CasePriority represents the urgency level of a support case.
type CasePriority string

const (
CasePriorityCatastrophic CasePriority = "catastrophic"
CasePriorityCritical CasePriority = "critical"
CasePriorityHigh CasePriority = "high"
CasePriorityMedium CasePriority = "medium"
CasePriorityLow CasePriority = "low"
)

// CaseState represents the current workflow state of a support case.
type CaseState string

const (
CaseStateOpen CaseState = "open"
CaseStateWorkInProgress CaseState = "work_in_progress"
CaseStateWaitingOnWSO2 CaseState = "waiting_on_wso2"
CaseStateAwaitingInfo CaseState = "awaiting_info"
CaseStateReopened CaseState = "reopened"
CaseStateSolutionProposed CaseState = "solution_proposed"
CaseStateClosed CaseState = "closed"
)

// CaseSortField enumerates the columns available for sorting case search results.
type CaseSortField string

const (
CaseSortFieldCreatedAt CaseSortField = "created_at"
CaseSortFieldUpdatedAt CaseSortField = "updated_at"
CaseSortFieldClosedAt CaseSortField = "closed_at"
)

// CaseSortOrder controls the sort direction.
type CaseSortOrder string

const (
CaseSortOrderAsc CaseSortOrder = "asc"
CaseSortOrderDesc CaseSortOrder = "desc"
)

// CaseSort specifies the sort field and direction for case search results.
type CaseSort struct {
Field CaseSortField `json:"field"`
Order CaseSortOrder `json:"order"`
}

// Case represents a customer support case as stored in the database.
// Priority and ClosedAt are optional and omitted from JSON when absent.
type Case struct {
ID string `json:"id"`
Number string `json:"number"`
Wso2ID string `json:"wso2Id"`
CreatedBy string `json:"createdBy"`
ProjectID string `json:"projectId"`
DeploymentID string `json:"deploymentId"`
DeployedProductID string `json:"deployedProductId"`
Subject string `json:"subject"`
Description string `json:"description"`
Priority CasePriority `json:"priority"`
IssueType CaseIssueType `json:"issueType"`
State CaseState `json:"state"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ClosedAt *time.Time `json:"closedAt,omitempty"`
}

// SearchCasesRequest is the input for a case search operation.
// SearchQuery is matched case-insensitively against subject, number, and wso2_id.
// All filter slices are optional. SortBy defaults to created_at.
type SearchCasesRequest struct {
Pagination Pagination `json:"pagination"`
SearchQuery string `json:"searchQuery"`
ProjectIDs []string `json:"projectIds"`
DeploymentIDs []string `json:"deploymentIds"`
DeployedProductIDs []string `json:"deployedProductIds"`
StateKeys []CaseState `json:"stateKeys"`
PriorityKeys []CasePriority `json:"priorityKeys"`
IssueTypeKeys []CaseIssueType `json:"issueTypeKeys"`
SortBy CaseSort `json:"sortBy"`
}

// SearchCasesResponse is the paginated result of a case search.
// HasMore is true when additional pages are available beyond the current offset.
type SearchCasesResponse struct {
Cases []Case `json:"cases"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
HasMore bool `json:"hasMore"`
}
51 changes: 51 additions & 0 deletions entity-service/internal/handler/case_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

// Package handler is declared in user_handler.go.
package handler

import (
"encoding/json"
"net/http"

"github.com/wso2-open-operations/cs-tools/entity-service/internal/domain"
"github.com/wso2-open-operations/cs-tools/entity-service/internal/service"
)

// CaseHandler handles HTTP requests for the case resource.
type CaseHandler struct {
svc service.CaseService
}

// NewCaseHandler constructs a CaseHandler with the given service.
func NewCaseHandler(svc service.CaseService) *CaseHandler {
return &CaseHandler{svc: svc}
}

// SearchCases handles POST /cases/search.
func (h *CaseHandler) SearchCases(w http.ResponseWriter, r *http.Request) {
var req domain.SearchCasesRequest
if !decodeRequest(w, r, &req) {
return
}
resp, err := h.svc.SearchCases(r.Context(), req)
if err != nil {
writeServiceError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
168 changes: 168 additions & 0 deletions entity-service/internal/repository/case_repo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package repository

import (
"context"
"fmt"
"strings"

"github.com/jackc/pgx/v5/pgxpool"
"github.com/wso2-open-operations/cs-tools/entity-service/internal/domain"
"golang.org/x/sync/errgroup"
)

// CaseRepository defines the persistence operations for the cases table.
type CaseRepository interface {
// SearchCases returns a filtered, paginated slice of cases together with the
// total count of matching rows before pagination.
// COUNT and SELECT are executed concurrently on separate pool connections.
SearchCases(ctx context.Context, req domain.SearchCasesRequest) ([]domain.Case, int, error)
}

type caseRepo struct {
db *pgxpool.Pool
}

// NewCaseRepository constructs a CaseRepository backed by the given connection pool.
func NewCaseRepository(db *pgxpool.Pool) CaseRepository {
return &caseRepo{db: db}
}

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

where := "WHERE 1=1"

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

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

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

if len(req.StateKeys) > 0 {
stateStrings := make([]string, len(req.StateKeys))
for i, s := range req.StateKeys {
stateStrings[i] = string(s)
}
where += fmt.Sprintf(" AND state = ANY($%d::case_state_enum[])", argIdx)
filterArgs = append(filterArgs, stateStrings)
argIdx++
}

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

if len(req.IssueTypeKeys) > 0 {
issueTypeStrings := make([]string, len(req.IssueTypeKeys))
for i, it := range req.IssueTypeKeys {
issueTypeStrings[i] = string(it)
}
where += fmt.Sprintf(" AND issue_type = ANY($%d::case_issue_type_enum[])", argIdx)
filterArgs = append(filterArgs, issueTypeStrings)
argIdx++
}

if req.SearchQuery != "" {
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(req.SearchQuery)
pattern := "%" + escaped + "%"
where += fmt.Sprintf(` AND (subject ILIKE $%d ESCAPE '\' OR number ILIKE $%d ESCAPE '\' OR wso2_id ILIKE $%d ESCAPE '\')`, argIdx, argIdx, argIdx)
filterArgs = append(filterArgs, pattern)
argIdx++
}

sortCol := string(req.SortBy.Field)
sortDir := string(req.SortBy.Order)

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

dataQuery := fmt.Sprintf(
`SELECT id, number, wso2_id, created_by, project_id, deployment_id, deployed_product_id,
subject, description, priority, issue_type, state, created_at, updated_at, closed_at
FROM cases %s
ORDER BY %s %s NULLS LAST, id
LIMIT $%d OFFSET $%d`,
where, sortCol, sortDir, argIdx, argIdx+1,
)
dataArgs := append(append([]any{}, filterArgs...), req.Pagination.Limit, req.Pagination.Offset)

var total int
var cases []domain.Case

eg, egCtx := errgroup.WithContext(ctx)

eg.Go(func() error {
if err := r.db.QueryRow(egCtx, countQuery, filterArgs...).Scan(&total); err != nil {
return fmt.Errorf("count cases: %w", err)
}
return nil
})

eg.Go(func() error {
rows, err := r.db.Query(egCtx, dataQuery, dataArgs...)
if err != nil {
return fmt.Errorf("query cases: %w", err)
}
defer rows.Close()

result := make([]domain.Case, 0, req.Pagination.Limit)
for rows.Next() {
var c domain.Case
if err := rows.Scan(
&c.ID, &c.Number, &c.Wso2ID, &c.CreatedBy,
&c.ProjectID, &c.DeploymentID, &c.DeployedProductID,
&c.Subject, &c.Description, &c.Priority, &c.IssueType, &c.State,
&c.CreatedAt, &c.UpdatedAt, &c.ClosedAt,
); err != nil {
return fmt.Errorf("scan case: %w", err)
}
result = append(result, c)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate cases: %w", err)
}
cases = result
return nil
})

if err := eg.Wait(); err != nil {
return nil, 0, err
}

return cases, total, nil
}
5 changes: 5 additions & 0 deletions entity-service/internal/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ func NewRouter(db *pgxpool.Pool) http.Handler {
deployedProductSvc := service.NewDeployedProductService(deployedProductRepo)
deployedProductHandler := handler.NewDeployedProductHandler(deployedProductSvc)

caseRepo := repository.NewCaseRepository(db)
caseSvc := service.NewCaseService(caseRepo)
caseHandler := handler.NewCaseHandler(caseSvc)

mux := http.NewServeMux()

mux.HandleFunc("GET /health", handler.HealthCheck)
Expand All @@ -69,6 +73,7 @@ func NewRouter(db *pgxpool.Pool) http.Handler {
mux.HandleFunc("POST /products/{id}/versions/search", productVersionHandler.SearchProductVersions)
mux.HandleFunc("POST /deployments/search", deploymentHandler.SearchDeployments)
mux.HandleFunc("POST /deployed-products/search", deployedProductHandler.SearchDeployedProducts)
mux.HandleFunc("POST /cases/search", caseHandler.SearchCases)

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