Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e776694
[CSM] feat: add POST /time-cards/search and PATCH /change-requests/{id}
Rashmika998 Jun 28, 2026
33ddad4
Add PatchChangeRequest to mockEntityChangeRequestClient in tests
Rashmika998 Jun 28, 2026
cfe3d67
Fix time cards parse error: drop id field from snTimeCardLabel
Rashmika998 Jun 28, 2026
eef3b7e
Add all time card states: pending, submitted, rejected, processed, re…
Rashmika998 Jun 28, 2026
87d7f9a
Merge upstream/v2 into feat/time-cards-and-patch-change-request: keep…
Rashmika998 Jun 28, 2026
83eee54
[CSM] feat: add DELETE /cases/{id}/attachments/{attachmentId} endpoint
Rashmika998 Jun 28, 2026
374ef92
fix: use camelCase {attachmentId} in entity service DELETE attachment…
Rashmika998 Jun 28, 2026
37ccf9a
fix: address CodeRabbit review on time-cards and change-request endpo…
Rashmika998 Jun 28, 2026
7ad5ca8
refactor: move attachment endpoints out of /cases/{id} path scope
Rashmika998 Jun 28, 2026
5c8cbcf
fix: update attachment handler tests for new path-param-free routes
Rashmika998 Jun 28, 2026
ebc2d08
refactor: use {id} path param for GET/DELETE attachment endpoints
Rashmika998 Jun 28, 2026
bd308db
fix: align attachment search payload with Ballerina service contract
Rashmika998 Jun 28, 2026
f15e05d
fix: align attachment create payload with Ballerina service contract
Rashmika998 Jun 28, 2026
cb90690
docs: fix attachment path params in README to use {id} instead of {at…
Rashmika998 Jun 28, 2026
3948577
Merge upstream/v2: add product vulnerabilities endpoints alongside ti…
Rashmika998 Jun 28, 2026
20b0964
fix: replace caseId with referenceId/referenceType in Attachment resp…
Rashmika998 Jun 28, 2026
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
6 changes: 6 additions & 0 deletions apps/csm-portal/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ backend/
- `POST /cases/{id}/attachments` — Upload an attachment to a case
- `POST /cases/{id}/attachments/search` — Search attachments on a case
- `GET /cases/{case_id}/attachments/{attachment_id}/content` — Download an attachment
- `DELETE /cases/{id}/attachments/{attachmentId}` — Delete an attachment (ServiceNow only)
- `POST /cases/{id}/call-requests` — Create a call request for a case (ServiceNow only)
- `POST /cases/{id}/call-requests/search` — Search call requests for a case (ServiceNow only)
- `PATCH /cases/{id}/call-requests/{callRequestId}` — Update a call request (ServiceNow only)
Expand Down Expand Up @@ -207,8 +208,13 @@ backend/
### Change Requests

- `GET /change-requests/{id}` — Get change request by ID (ServiceNow data source only)
- `PATCH /change-requests/{id}` — Update a change request (`plannedStartOn`, `isCustomerApproved`, `isCustomerReviewed`; ServiceNow data source only)
- `POST /change-requests/search` — Search change requests (ServiceNow data source only)

### Time Cards

- `POST /time-cards/search` — Search time cards; requires `pagination`, optional `filters` (`projectIds`, `startDate`, `endDate`, `states`) (ServiceNow data source only)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### Catalogs

- `POST /catalogs/search` — Search service catalogs by deployed product (ServiceNow only)
Expand Down
4 changes: 4 additions & 0 deletions apps/csm-portal/backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func main() {
deploymentHandler := handler.NewDeploymentHandler(entityClient)
changeRequestHandler := handler.NewChangeRequestHandler(entityClient)
catalogHandler := handler.NewCatalogHandler(entityClient)
timeCardHandler := handler.NewTimeCardHandler(entityClient)

updatesCfg := updates.Config{
BaseURL: mustEnv("UPDATES_BASE_URL"),
Expand Down Expand Up @@ -97,6 +98,7 @@ func main() {
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("DELETE /cases/{id}/attachments/{attachmentId}", caseHandler.DeleteCaseAttachment)
mux.HandleFunc("POST /cases/{id}/call-requests", caseHandler.CreateCallRequest)
mux.HandleFunc("POST /cases/{id}/call-requests/search", caseHandler.SearchCallRequests)
mux.HandleFunc("PATCH /cases/{caseId}/call-requests/{callRequestId}", caseHandler.PatchCallRequest)
Expand All @@ -119,7 +121,9 @@ func main() {
mux.HandleFunc("POST /deployments/{id}/products/search", deploymentHandler.SearchDeployedProducts)
mux.HandleFunc("PATCH /deployments/{deploymentId}/products/{productId}", deploymentHandler.PatchDeployedProduct)
mux.HandleFunc("GET /change-requests/{id}", changeRequestHandler.GetChangeRequest)
mux.HandleFunc("PATCH /change-requests/{id}", changeRequestHandler.PatchChangeRequest)
mux.HandleFunc("POST /change-requests/search", changeRequestHandler.SearchChangeRequests)
mux.HandleFunc("POST /time-cards/search", timeCardHandler.SearchTimeCards)
mux.HandleFunc("POST /catalogs/search", catalogHandler.SearchCatalogs)
mux.HandleFunc("GET /catalogs/{catalogId}/items/{catalogItemId}/variables", catalogHandler.GetCatalogItemVariables)

Expand Down
18 changes: 18 additions & 0 deletions apps/csm-portal/backend/internal/entity/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,18 @@ func (c *Client) GetChangeRequest(ctx context.Context, id string) ([]byte, error
return c.do(ctx, http.MethodGet, fmt.Sprintf("/change-requests/%s", url.PathEscape(id)), nil)
}

// PatchChangeRequest calls PATCH /change-requests/{id} on the entity service.
// Response is returned as raw JSON.
func (c *Client) PatchChangeRequest(ctx context.Context, id string, body []byte) ([]byte, error) {
return c.do(ctx, http.MethodPatch, fmt.Sprintf("/change-requests/%s", url.PathEscape(id)), body)
}

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

// CreateCaseAttachment calls POST /cases/{id}/attachments on the entity service.
// Response is returned as raw JSON.
func (c *Client) CreateCaseAttachment(ctx context.Context, caseID string, body []byte) ([]byte, error) {
Expand All @@ -167,6 +179,12 @@ func (c *Client) GetCaseAttachmentContent(ctx context.Context, caseID, attachmen
return c.doBinary(ctx, fmt.Sprintf("/cases/%s/attachments/%s/content", url.PathEscape(caseID), url.PathEscape(attachmentID)))
}

// DeleteCaseAttachment calls DELETE /cases/{id}/attachments/{attachmentId} on the entity service.
// Response is returned as raw JSON.
func (c *Client) DeleteCaseAttachment(ctx context.Context, caseID, attachmentID string) ([]byte, error) {
return c.do(ctx, http.MethodDelete, fmt.Sprintf("/cases/%s/attachments/%s", url.PathEscape(caseID), url.PathEscape(attachmentID)), nil)
}

// SearchCatalogs calls POST /catalogs/search on the entity service.
// Response is returned as raw JSON.
func (c *Client) SearchCatalogs(ctx context.Context, body []byte) ([]byte, error) {
Expand Down
30 changes: 30 additions & 0 deletions apps/csm-portal/backend/internal/handler/cases.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type entityCaseClient interface {
CreateCaseAttachment(ctx context.Context, caseID string, body []byte) ([]byte, error)
SearchCaseAttachments(ctx context.Context, caseID string, body []byte) ([]byte, error)
GetCaseAttachmentContent(ctx context.Context, caseID, attachmentID string) ([]byte, string, error)
DeleteCaseAttachment(ctx context.Context, caseID, attachmentID string) ([]byte, error)
CreateCallRequest(ctx context.Context, body []byte) ([]byte, error)
SearchCallRequests(ctx context.Context, body []byte) ([]byte, error)
PatchCallRequest(ctx context.Context, callRequestID string, body []byte) ([]byte, error)
Expand Down Expand Up @@ -412,6 +413,35 @@ func (h *CaseHandler) GetCaseAttachmentContent(w http.ResponseWriter, r *http.Re
_, _ = w.Write(content)
}

// DeleteCaseAttachment handles DELETE /cases/{id}/attachments/{attachmentId}.
func (h *CaseHandler) DeleteCaseAttachment(w http.ResponseWriter, r *http.Request) {
user := middleware.UserInfoFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized)
return
}

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

result, err := h.entity.DeleteCaseAttachment(r.Context(), caseID, attachmentID)
if err != nil {
slog.ErrorContext(r.Context(), "entity DeleteCaseAttachment failed", "userID", user.UserID, "caseID", caseID, "attachmentID", attachmentID, "err", err)
mapUpstreamError(w, err, "Failed to delete case attachment.")
Comment thread
Rashmika998 marked this conversation as resolved.
Outdated
return
}

writeJSON(w, http.StatusOK, result)
}

// PatchCase handles PATCH /cases/{id}.
// Accepts state, severity, workState, watchList, or assigneeEmail and forwards to the entity service.
func (h *CaseHandler) PatchCase(w http.ResponseWriter, r *http.Request) {
Expand Down
42 changes: 42 additions & 0 deletions apps/csm-portal/backend/internal/handler/change_requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
type entityChangeRequestClient interface {
SearchChangeRequests(ctx context.Context, body []byte) ([]byte, error)
GetChangeRequest(ctx context.Context, id string) ([]byte, error)
PatchChangeRequest(ctx context.Context, id string, body []byte) ([]byte, error)
}

// ChangeRequestHandler handles HTTP requests for change-request operations.
Expand All @@ -43,6 +44,47 @@ func NewChangeRequestHandler(entity entityChangeRequestClient) *ChangeRequestHan
return &ChangeRequestHandler{entity: entity}
}

// PatchChangeRequest handles PATCH /change-requests/{id}.
func (h *ChangeRequestHandler) PatchChangeRequest(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
}

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
}

if len(body) > 0 && !json.Valid(body) {
writeError(w, http.StatusBadRequest, ErrMsgBadRequest)
return
}

result, err := h.entity.PatchChangeRequest(r.Context(), id, body)
if err != nil {
slog.ErrorContext(r.Context(), "entity PatchChangeRequest failed", "userID", user.UserID, "id", id, "err", err)
mapUpstreamError(w, err, "Failed to update change request.")
return
}

writeJSON(w, http.StatusOK, result)
}

// GetChangeRequest handles GET /change-requests/{id}.
func (h *ChangeRequestHandler) GetChangeRequest(w http.ResponseWriter, r *http.Request) {
user := middleware.UserInfoFromContext(r.Context())
Expand Down
16 changes: 16 additions & 0 deletions apps/csm-portal/backend/internal/handler/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ type mockEntityCaseClient struct {
createCaseAttachmentFn func(ctx context.Context, caseID string, body []byte) ([]byte, error)
searchCaseAttachmentsFn func(ctx context.Context, caseID string, body []byte) ([]byte, error)
getCaseAttachmentContentFn func(ctx context.Context, caseID, attachmentID string) ([]byte, string, error)
deleteCaseAttachmentFn func(ctx context.Context, caseID, attachmentID string) ([]byte, error)
createCallRequestFn func(ctx context.Context, body []byte) ([]byte, error)
searchCallRequestsFn func(ctx context.Context, body []byte) ([]byte, error)
patchCallRequestFn func(ctx context.Context, callRequestID string, body []byte) ([]byte, error)
Expand Down Expand Up @@ -162,6 +163,13 @@ func (m *mockEntityCaseClient) GetCaseAttachmentContent(ctx context.Context, cas
return []byte(`fake-content`), "image/png", nil
}

func (m *mockEntityCaseClient) DeleteCaseAttachment(ctx context.Context, caseID, attachmentID string) ([]byte, error) {
if m.deleteCaseAttachmentFn != nil {
return m.deleteCaseAttachmentFn(ctx, caseID, attachmentID)
}
return []byte(`{"message":"Attachment deleted successfully."}`), nil
}

func (m *mockEntityCaseClient) CreateCallRequest(ctx context.Context, body []byte) ([]byte, error) {
if m.createCallRequestFn != nil {
return m.createCallRequestFn(ctx, body)
Expand Down Expand Up @@ -306,6 +314,7 @@ func (m *mockEntityProductClient) SearchProductVersions(ctx context.Context, pro
type mockEntityChangeRequestClient struct {
searchChangeRequestsFn func(ctx context.Context, body []byte) ([]byte, error)
getChangeRequestFn func(ctx context.Context, id string) ([]byte, error)
patchChangeRequestFn func(ctx context.Context, id string, body []byte) ([]byte, error)
}

func (m *mockEntityChangeRequestClient) SearchChangeRequests(ctx context.Context, body []byte) ([]byte, error) {
Expand All @@ -322,6 +331,13 @@ func (m *mockEntityChangeRequestClient) GetChangeRequest(ctx context.Context, id
return []byte(`{}`), nil
}

func (m *mockEntityChangeRequestClient) PatchChangeRequest(ctx context.Context, id string, body []byte) ([]byte, error) {
if m.patchChangeRequestFn != nil {
return m.patchChangeRequestFn(ctx, id, body)
}
return []byte(`{"id":"11111111-1111-1111-1111-111111111111","updatedOn":"2026-01-01T00:00:00Z","updatedBy":"user@example.com"}`), nil
}

// ----- mock entity deployment client -----

type mockEntityDeploymentClient struct {
Expand Down
78 changes: 78 additions & 0 deletions apps/csm-portal/backend/internal/handler/time_cards.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// 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"
)

// entityTimeCardClient abstracts the entity service time-card operations.
type entityTimeCardClient interface {
SearchTimeCards(ctx context.Context, body []byte) ([]byte, error)
}

// TimeCardHandler handles HTTP requests for time-card operations.
type TimeCardHandler struct {
entity entityTimeCardClient
}

// NewTimeCardHandler creates a TimeCardHandler backed by the given entity client.
func NewTimeCardHandler(entity entityTimeCardClient) *TimeCardHandler {
return &TimeCardHandler{entity: entity}
}

// SearchTimeCards handles POST /time-cards/search.
func (h *TimeCardHandler) SearchTimeCards(w http.ResponseWriter, r *http.Request) {
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
}

if len(body) > 0 && !json.Valid(body) {
writeError(w, http.StatusBadRequest, ErrMsgBadRequest)
return
}

result, err := h.entity.SearchTimeCards(r.Context(), body)
if err != nil {
slog.ErrorContext(r.Context(), "entity SearchTimeCards failed", "userID", user.UserID, "err", err)
mapUpstreamError(w, err, "Failed to search time cards.")
return
}

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