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
52 changes: 49 additions & 3 deletions entity-service/internal/domain/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,11 @@ type ProductVersion struct {
}

// SearchProductVersionsRequest is the input for a product version search operation.
// ProductID and SearchQuery are both optional; when provided they filter by product
// and version string respectively (case-insensitive).
// ProductID is populated from the URL path parameter and is not part of the JSON body.
// SearchQuery is optional and matched case-insensitively against the version string.
type SearchProductVersionsRequest struct {
Pagination Pagination `json:"pagination"`
ProductID string `json:"productId"`
ProductID string `json:"-"`
SearchQuery string `json:"searchQuery"`
}

Expand All @@ -237,3 +237,49 @@ type SearchProductVersionsResponse struct {
Offset int `json:"offset"`
HasMore bool `json:"hasMore"`
}

// DeploymentType classifies the environment role of a deployment.
type DeploymentType string

const (
DeploymentTypePrimaryProduction DeploymentType = "primary_production"
DeploymentTypeStaging DeploymentType = "staging"
DeploymentTypeQA DeploymentType = "qa"
DeploymentTypeStress DeploymentType = "stress"
DeploymentTypeUAT DeploymentType = "uat"
DeploymentTypeDevelopment DeploymentType = "development"
)

// Deployment represents a project deployment environment as stored in the database.
// Description is optional and omitted from JSON when absent.
type Deployment struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Name string `json:"name"`
Type DeploymentType `json:"type"`
Description *string `json:"description,omitempty"`
CreatedBy string `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

// SearchDeploymentsRequest is the input for a deployment search operation.
// All filter fields are optional. ProjectIDs scopes results to specific projects;
// DeploymentTypeKeys filters by deployment type; SearchQuery is matched
// case-insensitively against name.
type SearchDeploymentsRequest struct {
Pagination Pagination `json:"pagination"`
SearchQuery string `json:"searchQuery"`
ProjectIDs []string `json:"projectIds"`
DeploymentTypeKeys []DeploymentType `json:"deploymentTypeKeys"`
}

// SearchDeploymentsResponse is the paginated result of a deployment search.
// HasMore is true when additional pages are available beyond the current offset.
type SearchDeploymentsResponse struct {
Deployments []Deployment `json:"deployments"`
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/deployment_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"
)

// DeploymentHandler handles HTTP requests for the deployment resource.
type DeploymentHandler struct {
svc service.DeploymentService
}

// NewDeploymentHandler constructs a DeploymentHandler with the given service.
func NewDeploymentHandler(svc service.DeploymentService) *DeploymentHandler {
return &DeploymentHandler{svc: svc}
}

// SearchDeployments handles POST /deployments/search.
func (h *DeploymentHandler) SearchDeployments(w http.ResponseWriter, r *http.Request) {
var req domain.SearchDeploymentsRequest
if !decodeRequest(w, r, &req) {
return
}
resp, err := h.svc.SearchDeployments(r.Context(), req)
if err != nil {
writeServiceError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
3 changes: 2 additions & 1 deletion entity-service/internal/handler/product_version_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ func NewProductVersionHandler(svc service.ProductVersionService) *ProductVersion
return &ProductVersionHandler{svc: svc}
}

// SearchProductVersions handles POST /product-versions/search.
// SearchProductVersions handles POST /products/{id}/versions/search.
func (h *ProductVersionHandler) SearchProductVersions(w http.ResponseWriter, r *http.Request) {
var req domain.SearchProductVersionsRequest
if !decodeRequest(w, r, &req) {
return
}
req.ProductID = r.PathValue("id")
resp, err := h.svc.SearchProductVersions(r.Context(), req)
if err != nil {
writeServiceError(w, r, err)
Expand Down
133 changes: 133 additions & 0 deletions entity-service/internal/repository/deployment_repo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// 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"
)

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

type deploymentRepo struct {
db *pgxpool.Pool
}

// NewDeploymentRepository constructs a DeploymentRepository backed by the given connection pool.
func NewDeploymentRepository(db *pgxpool.Pool) DeploymentRepository {
return &deploymentRepo{db: db}
}

// SearchDeployments implements DeploymentRepository.
func (r *deploymentRepo) SearchDeployments(ctx context.Context, req domain.SearchDeploymentsRequest) ([]domain.Deployment, int, error) {
filterArgs := []any{}
argIdx := 1

where := "WHERE 1=1"

if len(req.ProjectIDs) > 0 {
// Cast the parameter to uuid[] so the column stays uncast and idx_deployments_project_id is usable.
where += fmt.Sprintf(" AND project_id = ANY($%d::uuid[])", argIdx)
filterArgs = append(filterArgs, req.ProjectIDs)
argIdx++
}

if len(req.DeploymentTypeKeys) > 0 {
// Convert []DeploymentType to []string — pgx has no codec for named string types.
// Cast the parameter to deployment_type_enum[] so the column stays uncast and idx_deployments_type is usable.
typeStrings := make([]string, len(req.DeploymentTypeKeys))
for i, t := range req.DeploymentTypeKeys {
typeStrings[i] = string(t)
}
where += fmt.Sprintf(" AND type = ANY($%d::deployment_type_enum[])", argIdx)
filterArgs = append(filterArgs, typeStrings)
argIdx++
Comment thread
cloby99 marked this conversation as resolved.
Resolved
}

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

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

dataQuery := fmt.Sprintf(
`SELECT id, project_id, name, type, description, created_by, created_at, updated_at
FROM deployments %s
ORDER BY created_at DESC, id
LIMIT $%d OFFSET $%d`,
where, argIdx, argIdx+1,
)
dataArgs := append(append([]any{}, filterArgs...), req.Pagination.Limit, req.Pagination.Offset)

var total int
var deployments []domain.Deployment

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 deployments: %w", err)
}
return nil
})

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

result := make([]domain.Deployment, 0, req.Pagination.Limit)
for rows.Next() {
var d domain.Deployment
if err := rows.Scan(
&d.ID, &d.ProjectID, &d.Name, &d.Type, &d.Description, &d.CreatedBy,
&d.CreatedAt, &d.UpdatedAt,
); err != nil {
return fmt.Errorf("scan deployment: %w", err)
}
result = append(result, d)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate deployments: %w", err)
}
deployments = result
return nil
})

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

return deployments, total, nil
}
7 changes: 6 additions & 1 deletion entity-service/internal/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,19 @@ func NewRouter(db *pgxpool.Pool) http.Handler {
productVersionSvc := service.NewProductVersionService(productVersionRepo)
productVersionHandler := handler.NewProductVersionHandler(productVersionSvc)

deploymentRepo := repository.NewDeploymentRepository(db)
deploymentSvc := service.NewDeploymentService(deploymentRepo)
deploymentHandler := handler.NewDeploymentHandler(deploymentSvc)

mux := http.NewServeMux()

mux.HandleFunc("GET /health", handler.HealthCheck)
mux.HandleFunc("POST /users/search", userHandler.SearchUsers)
mux.HandleFunc("POST /accounts/search", accountHandler.SearchAccounts)
mux.HandleFunc("POST /projects/search", projectHandler.SearchProjects)
mux.HandleFunc("POST /products/search", productHandler.SearchProducts)
mux.HandleFunc("POST /product-versions/search", productVersionHandler.SearchProductVersions)
mux.HandleFunc("POST /products/{id}/versions/search", productVersionHandler.SearchProductVersions)
mux.HandleFunc("POST /deployments/search", deploymentHandler.SearchDeployments)

return middleware.Recovery(
middleware.Logger(
Expand Down
57 changes: 57 additions & 0 deletions entity-service/internal/service/deployment_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// 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 service is declared in interfaces.go.
package service

import (
"context"

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

type deploymentService struct {
repo repository.DeploymentRepository
}

// NewDeploymentService constructs a DeploymentService backed by the given repository.
func NewDeploymentService(repo repository.DeploymentRepository) DeploymentService {
return &deploymentService{repo: repo}
}

// SearchDeployments implements DeploymentService.
func (s *deploymentService) SearchDeployments(ctx context.Context, req domain.SearchDeploymentsRequest) (domain.SearchDeploymentsResponse, error) {
if err := normalizePagination(&req.Pagination); err != nil {
return domain.SearchDeploymentsResponse{}, err
}
if err := validateSearchQuery(req.SearchQuery); err != nil {
return domain.SearchDeploymentsResponse{}, err
}

deployments, total, err := s.repo.SearchDeployments(ctx, req)
if err != nil {
return domain.SearchDeploymentsResponse{}, err
}

return domain.SearchDeploymentsResponse{
Deployments: deployments,
Total: total,
Limit: req.Pagination.Limit,
Offset: req.Pagination.Offset,
HasMore: req.Pagination.Offset+len(deployments) < total,
}, nil
}
8 changes: 8 additions & 0 deletions entity-service/internal/service/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,11 @@ type ProductVersionService interface {
// for invalid input; any other error indicates an infrastructure failure.
SearchProductVersions(ctx context.Context, req domain.SearchProductVersionsRequest) (domain.SearchProductVersionsResponse, error)
}

// DeploymentService defines the operations available on the deployment entity.
type DeploymentService interface {
// SearchDeployments returns a paginated list of deployments filtered by optional
// project IDs, deployment type keys, and name search query. A ValidationError is
// returned for invalid input; any other error indicates an infrastructure failure.
SearchDeployments(ctx context.Context, req domain.SearchDeploymentsRequest) (domain.SearchDeploymentsResponse, error)
}
2 changes: 2 additions & 0 deletions entity-service/migrations/000006_create_deployments.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS deployments;
DROP TYPE IF EXISTS deployment_type_enum;
26 changes: 26 additions & 0 deletions entity-service/migrations/000006_create_deployments.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
CREATE TYPE deployment_type_enum AS ENUM (
'primary_production',
'staging',
'qa',
'stress',
'uat',
'development'
);
Comment thread
cloby99 marked this conversation as resolved.
Resolved

CREATE TABLE IF NOT EXISTS deployments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
type deployment_type_enum NOT NULL,
description TEXT,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

CONSTRAINT uq_deployment_project_name UNIQUE (project_id, name)
);

CREATE INDEX IF NOT EXISTS idx_deployments_project_id ON deployments (project_id);
CREATE INDEX IF NOT EXISTS idx_deployments_created_by ON deployments (created_by);
CREATE INDEX IF NOT EXISTS idx_deployments_type ON deployments (type);
CREATE INDEX IF NOT EXISTS idx_deployments_project_type ON deployments (project_id, type);