diff --git a/apps/customer-portal/backend-v2/internal/dto/comment.go b/apps/customer-portal/backend-v2/internal/dto/comment.go index c2646a74ef..25621a573b 100644 --- a/apps/customer-portal/backend-v2/internal/dto/comment.go +++ b/apps/customer-portal/backend-v2/internal/dto/comment.go @@ -98,6 +98,11 @@ type CommentView struct { CreatedBy string `json:"createdBy"` CreatedByFirstName string `json:"createdByFirstName,omitempty"` CreatedByLastName string `json:"createdByLastName,omitempty"` + // Inline images in the comment body. The frontend declares both on its + // CaseCommentInlineAttachment type (features/support/types/attachments.ts) + // and renders them in case and conversation threads. + HasInlineAttachments bool `json:"hasInlineAttachments,omitempty"` + InlineAttachments []CommentInlineAttachment `json:"inlineAttachments,omitempty"` } // SearchCommentsResponse is the portal's response for POST /comments/search @@ -112,18 +117,50 @@ type SearchCommentsResponse struct { HasMore bool `json:"hasMore"` } +// CommentInlineAttachment is an image embedded in a comment body. Field names +// match the frontend's CaseCommentInlineAttachment exactly. +type CommentInlineAttachment struct { + ID string `json:"id"` + FileName string `json:"fileName"` + ContentType string `json:"contentType"` + DownloadURL string `json:"downloadUrl"` + // Nil when the upstream had no parseable timestamp; omitted rather than + // serialised as a year-one date. + CreatedOn *time.Time `json:"createdOn,omitempty"` + CreatedBy string `json:"createdBy"` +} + +// mapCommentInlineAttachments converts entity-service's inline attachments to +// the portal shape. Returns nil (omitted) rather than an empty slice when the +// comment has none, so a plain comment does not grow an empty array. +func mapCommentInlineAttachments(in []entity.InlineAttachment) []CommentInlineAttachment { + if len(in) == 0 { + return nil + } + out := make([]CommentInlineAttachment, 0, len(in)) + for _, a := range in { + out = append(out, CommentInlineAttachment{ + ID: a.ID, FileName: a.FileName, ContentType: a.ContentType, + DownloadURL: a.DownloadURL, CreatedOn: a.CreatedOn, CreatedBy: a.CreatedBy, + }) + } + return out +} + // MapSearchComments builds the portal response from entity-service's SearchCommentsResponse. func MapSearchComments(r entity.SearchCommentsResponse) SearchCommentsResponse { comments := make([]CommentView, 0, len(r.Comments)) for _, c := range r.Comments { comments = append(comments, CommentView{ - ID: c.ID, - Content: c.Content, - Type: string(c.Type), - CreatedOn: c.CreatedOn, - CreatedBy: c.CreatedBy.FullName, - CreatedByFirstName: c.CreatedBy.FirstName, - CreatedByLastName: c.CreatedBy.LastName, + ID: c.ID, + Content: c.Content, + Type: string(c.Type), + CreatedOn: c.CreatedOn, + CreatedBy: c.CreatedBy.FullName, + CreatedByFirstName: c.CreatedBy.FirstName, + CreatedByLastName: c.CreatedBy.LastName, + HasInlineAttachments: c.HasInlineAttachments, + InlineAttachments: mapCommentInlineAttachments(c.InlineAttachments), }) } return SearchCommentsResponse{ diff --git a/apps/customer-portal/backend-v2/internal/dto/comment_inline_attachments_test.go b/apps/customer-portal/backend-v2/internal/dto/comment_inline_attachments_test.go new file mode 100644 index 0000000000..5f911e88e5 --- /dev/null +++ b/apps/customer-portal/backend-v2/internal/dto/comment_inline_attachments_test.go @@ -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 dto + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/wso2-open-operations/cs-tools/apps/customer-portal/backend-v2/internal/entity" +) + +// TestMapSearchComments_EmitsInlineAttachments covers the fields the frontend +// declares on CaseCommentInlineAttachment (features/support/types/attachments.ts) +// and renders in case and conversation threads. entity-service was discarding +// them from the upstream comment payload, so they never reached the portal. +func TestMapSearchComments_EmitsInlineAttachments(t *testing.T) { + created := time.Date(2026, 8, 18, 9, 0, 0, 0, time.UTC) + got := MapSearchComments(entity.SearchCommentsResponse{ + Comments: []entity.CommentView{{ + ID: "c1", + Content: "see screenshot", + HasInlineAttachments: true, + InlineAttachments: []entity.InlineAttachment{{ + ID: "a1", FileName: "shot.png", ContentType: "image/png", + DownloadURL: "https://sn.example/a1.iix", CreatedOn: &created, CreatedBy: "user-1", + }}, + }}, + Total: 1, + }) + + raw, err := json.Marshal(got.Comments[0]) + if err != nil { + t.Fatalf("marshal returned error: %v", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + + if m["hasInlineAttachments"] != true { + t.Errorf("hasInlineAttachments = %v, want true", m["hasInlineAttachments"]) + } + list, ok := m["inlineAttachments"].([]any) + if !ok || len(list) != 1 { + t.Fatalf("inlineAttachments = %v, want one entry", m["inlineAttachments"]) + } + a := list[0].(map[string]any) + for k, want := range map[string]string{ + "id": "a1", "fileName": "shot.png", "contentType": "image/png", + "downloadUrl": "https://sn.example/a1.iix", "createdBy": "user-1", + } { + if a[k] != want { + t.Errorf("inlineAttachments[0].%s = %v, want %q", k, a[k], want) + } + } +} + +// TestMapSearchComments_OmitsWhenNoInlineAttachments keeps a plain comment from +// growing an empty array or a false flag — the frontend treats both keys as +// optional, so absent is the correct representation. +func TestMapSearchComments_OmitsWhenNoInlineAttachments(t *testing.T) { + got := MapSearchComments(entity.SearchCommentsResponse{ + Comments: []entity.CommentView{{ID: "c1", Content: "plain text"}}, + }) + + raw, err := json.Marshal(got.Comments[0]) + if err != nil { + t.Fatalf("marshal returned error: %v", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + for _, k := range []string{"hasInlineAttachments", "inlineAttachments"} { + if _, present := m[k]; present { + t.Errorf("%q present on a comment with no inline images; want omitted", k) + } + } +} + +// TestMapSearchComments_OmitsUnparseableAttachmentTimestamp is the regression +// guard for the pointer change. entity-service leaves CreatedOn nil when the +// upstream timestamp is missing or unparseable; a value type would have +// serialised Go's zero time as "0001-01-01T00:00:00Z", which reads as a genuine +// date. The key must be absent instead — never a year-one timestamp. +func TestMapSearchComments_OmitsUnparseableAttachmentTimestamp(t *testing.T) { + got := MapSearchComments(entity.SearchCommentsResponse{ + Comments: []entity.CommentView{{ + ID: "c1", + HasInlineAttachments: true, + InlineAttachments: []entity.InlineAttachment{{ + ID: "a1", FileName: "shot.png", CreatedOn: nil, CreatedBy: "user-1", + }}, + }}, + }) + + raw, err := json.Marshal(got.Comments[0]) + if err != nil { + t.Fatalf("marshal returned error: %v", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + a := m["inlineAttachments"].([]any)[0].(map[string]any) + + if v, present := a["createdOn"]; present && v != nil { + t.Errorf("createdOn = %v; want the key omitted (or null), never a zero timestamp", v) + } + if s, _ := a["createdOn"].(string); strings.HasPrefix(s, "0001-01-01") { + t.Errorf("createdOn = %q — Go's zero time leaked as a real-looking date", s) + } + // The rest of the attachment must still be intact. + if a["fileName"] != "shot.png" { + t.Errorf("fileName = %v, wanted the attachment still mapped", a["fileName"]) + } +} diff --git a/apps/customer-portal/backend-v2/internal/entity/types.go b/apps/customer-portal/backend-v2/internal/entity/types.go index c44760682c..1e416d92f6 100644 --- a/apps/customer-portal/backend-v2/internal/entity/types.go +++ b/apps/customer-portal/backend-v2/internal/entity/types.go @@ -1139,6 +1139,16 @@ type SearchCommentsRequest struct { } // CommentView is a single search result item from POST /comments/search. +// InlineAttachment is an image embedded in a comment body. +type InlineAttachment struct { + ID string `json:"id"` + FileName string `json:"fileName"` + ContentType string `json:"contentType"` + DownloadURL string `json:"downloadUrl"` + CreatedOn *time.Time `json:"createdOn"` + CreatedBy string `json:"createdBy"` +} + type CommentView struct { ID string `json:"id"` ReferenceID string `json:"referenceId"` @@ -1146,6 +1156,9 @@ type CommentView struct { Type CommentType `json:"type"` CreatedOn time.Time `json:"createdOn"` CreatedBy CommentUserRef `json:"createdBy"` + // Images embedded in the comment body, supplied by entity-service. + HasInlineAttachments bool `json:"hasInlineAttachments"` + InlineAttachments []InlineAttachment `json:"inlineAttachments"` } // SearchCommentsResponse is entity-service's response for POST /comments/search. diff --git a/entity-service/internal/domain/entity.go b/entity-service/internal/domain/entity.go index 8c33e7448e..502351f450 100644 --- a/entity-service/internal/domain/entity.go +++ b/entity-service/internal/domain/entity.go @@ -1969,6 +1969,19 @@ type CommentUserRef struct { } // CaseComment represents a comment on a support case. +// InlineAttachment is an image embedded in a comment body. IDs are converted +// from ServiceNow sysids to UUIDs like every other inbound identifier. +type InlineAttachment struct { + ID string `json:"id"` + FileName string `json:"fileName"` + ContentType string `json:"contentType"` + DownloadURL string `json:"downloadUrl"` + // CreatedOn is nil when the upstream supplied no parseable timestamp — never + // a zero time, which would serialise as a real-looking year-one date. + CreatedOn *time.Time `json:"createdOn,omitempty"` + CreatedBy string `json:"createdBy"` +} + type CaseComment struct { ID string `json:"id"` CaseID string `json:"caseId"` @@ -1982,6 +1995,11 @@ type CaseComment struct { // automation or integration account that is not a user. See UserReference. CreatedByUser *UserReference `json:"createdByUser"` CreatedOn time.Time `json:"createdOn"` + // HasInlineAttachments / InlineAttachments describe images embedded in the + // comment body. The upstream sends both; they were previously not decoded + // here at all. InlineAttachments is nil (not empty) when there are none. + HasInlineAttachments bool `json:"hasInlineAttachments"` + InlineAttachments []InlineAttachment `json:"inlineAttachments,omitempty"` } // CreateCaseCommentRequest is the input for creating a new case comment. diff --git a/entity-service/internal/service/sn_case_service.go b/entity-service/internal/service/sn_case_service.go index a95e283741..1dcb2f9794 100644 --- a/entity-service/internal/service/sn_case_service.go +++ b/entity-service/internal/service/sn_case_service.go @@ -1030,6 +1030,22 @@ type snComment struct { // author is not a real user (e.g. "system") and when the ServiceNow side // predates the field. See snUserRef. CreatedByUser *snUserRef `json:"createdByUser"` + // Inline attachments are the images embedded in a comment body. Declared + // here because ServiceNow sends them and this struct previously dropped + // them; shape follows the Ballerina entity-service InlineAttachment record. + HasInlineAttachments bool `json:"hasInlineAttachments"` + InlineAttachments []snInlineAttachment `json:"inlineAttachments"` +} + +// snInlineAttachment is an image embedded in a comment body, as ServiceNow +// returns it. IDs are sysids and are converted with sysidToUUID on the way out. +type snInlineAttachment struct { + ID string `json:"id"` + FileName string `json:"fileName"` + ContentType string `json:"contentType"` + DownloadURL string `json:"downloadUrl"` + CreatedOn string `json:"createdOn"` + CreatedBy string `json:"createdBy"` } type snSearchCommentsResponse struct { @@ -1100,6 +1116,27 @@ func (s *snCaseService) SearchCaseComments(ctx context.Context, req domain.Searc default: commentType = domain.CommentTypeComment } + // Inline attachments: sysid -> UUID like every other inbound ID, and the + // same createdOn layout. A parse failure on one image must not fail the + // whole comment page, so a bad timestamp leaves that entry's CreatedOn zero. + var inlineAttachments []domain.InlineAttachment + for _, ia := range c.InlineAttachments { + entry := domain.InlineAttachment{ + ID: sysidToUUID(ia.ID), + FileName: ia.FileName, + ContentType: ia.ContentType, + DownloadURL: ia.DownloadURL, + CreatedBy: ia.CreatedBy, + } + // Left nil for an empty or unparseable value: a zero time would render as + // "0001-01-01T00:00:00Z" and read as a genuine timestamp. + if ia.CreatedOn != "" { + if parsed, err := time.Parse(snCreatedOnLayout, ia.CreatedOn); err == nil { + entry.CreatedOn = &parsed + } + } + inlineAttachments = append(inlineAttachments, entry) + } comments = append(comments, domain.CaseComment{ ID: sysidToUUID(c.ID), CaseID: sysidToUUID(c.ReferenceID), @@ -1111,8 +1148,10 @@ func (s *snCaseService) SearchCaseComments(ctx context.Context, req domain.Searc LastName: c.CreatedByLastName, FullName: c.CreatedByFullName, }, - CreatedByUser: snUserReference(c.CreatedByUser, c.CreatedBy, c.CreatedByFullName), - CreatedOn: createdAt, + CreatedByUser: snUserReference(c.CreatedByUser, c.CreatedBy, c.CreatedByFullName), + CreatedOn: createdAt, + HasInlineAttachments: c.HasInlineAttachments, + InlineAttachments: inlineAttachments, }) }