Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
5 changes: 5 additions & 0 deletions apps/csm-portal/backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func main() {
itServiceHandler := handler.NewITServiceHandler(customerEntityClient)
serviceOfferingHandler := handler.NewServiceOfferingHandler(customerEntityClient)
groupHandler := handler.NewGroupHandler(customerEntityClient)
referenceHandler := handler.NewReferenceHandler(customerEntityClient)
configurationItemHandler := handler.NewConfigurationItemHandler(customerEntityClient)
catalogHandler := handler.NewCatalogHandler(customerEntityClient)
timeCardHandler := handler.NewTimeCardHandler(customerEntityClient)
Expand Down Expand Up @@ -143,12 +144,16 @@ func main() {
mux.HandleFunc("GET /users/me", usersHandler.GetMe)
mux.HandleFunc("PATCH /users/me", usersHandler.PatchMe)
mux.HandleFunc("POST /users/search", usersHandler.SearchUsers)
mux.HandleFunc("GET /users/{id}", usersHandler.GetUser)
mux.HandleFunc("POST /roles/search", referenceHandler.SearchRoles)
mux.HandleFunc("POST /teams/search", referenceHandler.SearchTeams)
mux.HandleFunc("GET /accounts/{id}", accountHandler.GetAccount)
mux.HandleFunc("POST /accounts/search", accountHandler.SearchAccounts)
mux.HandleFunc("POST /accounts/{id}/contacts/search", accountHandler.SearchAccountContacts)
mux.HandleFunc("GET /projects/{id}", projectHandler.GetProject)
mux.HandleFunc("POST /projects/search", projectHandler.SearchProjects)
mux.HandleFunc("POST /projects/{id}/contacts/search", projectHandler.SearchProjectContacts)
mux.HandleFunc("GET /projects/{id}/contacts/{contactId}", projectHandler.GetProjectContact)
mux.HandleFunc("PATCH /projects/{id}", projectHandler.UpdateProject)
mux.HandleFunc("POST /products/search", productHandler.SearchProducts)
mux.HandleFunc("POST /products/{id}/versions/search", productHandler.SearchProductVersions)
Expand Down
25 changes: 25 additions & 0 deletions apps/csm-portal/backend/internal/entity/customer.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,31 @@ func (c *CustomerEntityClient) SearchUsers(ctx context.Context, body []byte) ([]
return c.do(ctx, http.MethodPost, "/users/search", body)
}

// GetProjectContact calls GET /projects/{id}/contacts/{contactId} on the entity service.
// Response is returned as raw JSON.
func (c *CustomerEntityClient) GetProjectContact(ctx context.Context, projectID, contactID string) ([]byte, error) {
return c.do(ctx, http.MethodGet, fmt.Sprintf("/projects/%s/contacts/%s",
url.PathEscape(projectID), url.PathEscape(contactID)), nil)
}

// GetUser calls GET /users/{id} on the entity service.
// Response is returned as raw JSON.
func (c *CustomerEntityClient) GetUser(ctx context.Context, id string) ([]byte, error) {
return c.do(ctx, http.MethodGet, fmt.Sprintf("/users/%s", url.PathEscape(id)), nil)
}

// SearchRoles calls POST /roles/search on the entity service.
// Response is returned as raw JSON.
func (c *CustomerEntityClient) SearchRoles(ctx context.Context, body []byte) ([]byte, error) {
return c.do(ctx, http.MethodPost, "/roles/search", body)
}

// SearchTeams calls POST /teams/search on the entity service.
// Response is returned as raw JSON.
func (c *CustomerEntityClient) SearchTeams(ctx context.Context, body []byte) ([]byte, error) {
return c.do(ctx, http.MethodPost, "/teams/search", body)
}

// GetAccount calls GET /accounts/{id} on the entity service.
// Response is returned as raw JSON; typed response structs are deferred.
func (c *CustomerEntityClient) GetAccount(ctx context.Context, id string) ([]byte, error) {
Expand Down
36 changes: 36 additions & 0 deletions apps/csm-portal/backend/internal/handler/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,34 @@ type mockEntityUserClient struct {
getUserMeFn func(ctx context.Context) ([]byte, error)
patchUserMeFn func(ctx context.Context, body []byte) ([]byte, error)
searchUsersFn func(ctx context.Context, body []byte) ([]byte, error)
getUserFn func(ctx context.Context, id string) ([]byte, error)
}

func (m *mockEntityUserClient) GetUser(ctx context.Context, id string) ([]byte, error) {
if m.getUserFn != nil {
return m.getUserFn(ctx, id)
}
return []byte(`{"id":"` + id + `","email":"","roles":[],"groups":[],"teams":[]}`), nil
}

// mockEntityReferenceClient stubs the role-catalogue and team-registry calls.
type mockEntityReferenceClient struct {
searchRolesFn func(ctx context.Context, body []byte) ([]byte, error)
searchTeamsFn func(ctx context.Context, body []byte) ([]byte, error)
}

func (m *mockEntityReferenceClient) SearchRoles(ctx context.Context, body []byte) ([]byte, error) {
if m.searchRolesFn != nil {
return m.searchRolesFn(ctx, body)
}
return []byte(`{"roles":[],"total":0,"offset":0,"limit":50}`), nil
}

func (m *mockEntityReferenceClient) SearchTeams(ctx context.Context, body []byte) ([]byte, error) {
if m.searchTeamsFn != nil {
return m.searchTeamsFn(ctx, body)
}
return []byte(`{"teams":[],"total":0,"offset":0,"limit":50}`), nil
}

func (m *mockEntityUserClient) GetUserMe(ctx context.Context) ([]byte, error) {
Expand Down Expand Up @@ -337,9 +365,17 @@ type mockEntityProjectClient struct {
getProjectFn func(ctx context.Context, id string) ([]byte, error)
searchProjectsFn func(ctx context.Context, body []byte) ([]byte, error)
searchProjectContactsFn func(ctx context.Context, projectID string, body []byte) ([]byte, error)
getProjectContactFn func(ctx context.Context, projectID, contactID string) ([]byte, error)
updateProjectFn func(ctx context.Context, id string, body []byte) ([]byte, error)
}

func (m *mockEntityProjectClient) GetProjectContact(ctx context.Context, projectID, contactID string) ([]byte, error) {
if m.getProjectContactFn != nil {
return m.getProjectContactFn(ctx, projectID, contactID)
}
return []byte(`{"id":"` + contactID + `","name":"","email":"","registrationState":"","notificationsEnabled":false,"roles":[]}`), nil
}

func (m *mockEntityProjectClient) GetProject(ctx context.Context, id string) ([]byte, error) {
if m.getProjectFn != nil {
return m.getProjectFn(ctx, id)
Expand Down
35 changes: 35 additions & 0 deletions apps/csm-portal/backend/internal/handler/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type entityProjectClient interface {
GetProject(ctx context.Context, id string) ([]byte, error)
SearchProjects(ctx context.Context, body []byte) ([]byte, error)
SearchProjectContacts(ctx context.Context, projectID string, body []byte) ([]byte, error)
GetProjectContact(ctx context.Context, projectID, contactID string) ([]byte, error)
UpdateProject(ctx context.Context, id string, body []byte) ([]byte, error)
}

Expand Down Expand Up @@ -148,6 +149,40 @@ func (h *ProjectHandler) SearchProjectContacts(w http.ResponseWriter, r *http.Re
writeJSON(w, http.StatusOK, result)
}

// GetProjectContact handles GET /projects/{id}/contacts/{contactId}.
//
// Returns one contact's attributes for a single project: their roles on it, their
// registration state and their notification preference.
func (h *ProjectHandler) GetProjectContact(w http.ResponseWriter, r *http.Request) {
user := middleware.UserInfoFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized)
return
}

id := r.PathValue("id")
if id == "" || !uuidRe.MatchString(id) {
writeError(w, http.StatusBadRequest, ErrMsgInvalidUUID)
return
}

contactID := r.PathValue("contactId")
if contactID == "" || !uuidRe.MatchString(contactID) {
writeError(w, http.StatusBadRequest, ErrMsgInvalidUUID)
return
}

result, err := h.entity.GetProjectContact(r.Context(), id, contactID)
if err != nil {
slog.ErrorContext(r.Context(), "entity GetProjectContact failed",
"userID", user.UserID, "projectID", id, "contactID", contactID, "err", err)
mapUpstreamError(w, err, "Failed to fetch the project contact.")
return
}

writeJSON(w, http.StatusOK, result)
}

// UpdateProject handles PATCH /projects/{id}.
// The endpoint is path-scoped, so the request body is capped and forwarded to the
// entity service as-is (no fields are injected) and the response is returned verbatim.
Expand Down
100 changes: 100 additions & 0 deletions apps/csm-portal/backend/internal/handler/reference.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// 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

import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"

"github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/middleware"
)

// entityReferenceClient abstracts the entity service reference-data operations: the role
// catalogue and the team registry, both of which back the user-directory filters.
type entityReferenceClient interface {
SearchRoles(ctx context.Context, body []byte) ([]byte, error)
SearchTeams(ctx context.Context, body []byte) ([]byte, error)
}

// ReferenceHandler handles HTTP requests for the role catalogue and team registry.
type ReferenceHandler struct {
entity entityReferenceClient
}

// NewReferenceHandler creates a ReferenceHandler backed by the given entity client.
func NewReferenceHandler(entity entityReferenceClient) *ReferenceHandler {
return &ReferenceHandler{entity: entity}
}

// SearchRoles handles POST /roles/search.
func (h *ReferenceHandler) SearchRoles(w http.ResponseWriter, r *http.Request) {
h.forward(w, r, "SearchRoles", "Failed to search roles.", h.entity.SearchRoles)
}

// SearchTeams handles POST /teams/search.
func (h *ReferenceHandler) SearchTeams(w http.ResponseWriter, r *http.Request) {
h.forward(w, r, "SearchTeams", "Failed to search teams.", h.entity.SearchTeams)
}

// forward carries the shared read-body / validate / passthrough sequence for both search
// endpoints. They differ only in which client call they make and what they are called in
// logs, so the sequence lives in one place rather than being duplicated per endpoint.
func (h *ReferenceHandler) forward(
w http.ResponseWriter,
r *http.Request,
op string,
failureMsg string,
call func(ctx context.Context, body []byte) ([]byte, error),
) {
user := middleware.UserInfoFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized)
return
}

r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, ErrMsgTooLarge)
return
}
writeError(w, http.StatusBadRequest, errMsgReadBody)
return
}

// Both endpoints accept an absent body, meaning "no filters, default page". Only a
// non-empty body has to be valid JSON.
if len(body) > 0 && !json.Valid(body) {
writeError(w, http.StatusBadRequest, ErrMsgBadRequest)
return
}

result, err := call(r.Context(), body)
if err != nil {
slog.ErrorContext(r.Context(), "entity "+op+" failed", "userID", user.UserID, "err", err)
mapUpstreamError(w, err, failureMsg)
return
}

writeJSON(w, http.StatusOK, result)
}
Loading