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
51 changes: 44 additions & 7 deletions apps/customer-portal/backend-v2/internal/dto/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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{
Expand Down
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 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"])
}
}
13 changes: 13 additions & 0 deletions apps/customer-portal/backend-v2/internal/entity/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -1139,13 +1139,26 @@ 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"`
Content string `json:"content"`
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.
Expand Down
18 changes: 18 additions & 0 deletions entity-service/internal/domain/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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.
Expand Down
43 changes: 41 additions & 2 deletions entity-service/internal/service/sn_case_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand All @@ -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,
})
}

Expand Down