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
30 changes: 30 additions & 0 deletions apps/csm-portal/backend/internal/directory/directory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,36 @@ func TestSearchTeams_FiltersOnNameOrKey(t *testing.T) {
}
}

func TestSearchTeams_FiltersOnFamily(t *testing.T) {
dir := mustDirectory(t, registryFixture, "")

if got := dir.SearchTeams(SearchRequest{Filters: SearchFilters{Family: "cre-abt"}}); got.Total != 1 || got.Teams[0].ID != "alpha" {
t.Fatalf("family=cre-abt matched %+v, want just alpha", got.Teams)
}
// Case-insensitive: the registry row spelled it "CRE-ABT" (see
// registryFixture), and parseFamily normalizes storage to lowercase, but a
// caller filtering with either case must match.
if got := dir.SearchTeams(SearchRequest{Filters: SearchFilters{Family: "CRE-ABT"}}); got.Total != 1 {
t.Errorf("family=CRE-ABT (uppercase) matched %d teams, want 1", got.Total)
}
if got := dir.SearchTeams(SearchRequest{Filters: SearchFilters{Family: "sre-abt"}}); got.Total != 1 || got.Teams[0].ID != "beta" {
t.Fatalf("family=sre-abt matched %+v, want just beta", got.Teams)
}
// gamma has no family at all -- a family filter must exclude it, not treat
// an empty Family field as a wildcard match.
if got := dir.SearchTeams(SearchRequest{Filters: SearchFilters{Family: "cre"}}); got.Total != 0 {
t.Errorf("family=cre matched %d teams, want 0 (none of alpha/beta/gamma is plain cre)", got.Total)
}
// No family filter at all: every team, same as before this field existed.
if got := dir.SearchTeams(SearchRequest{}); got.Total != 3 {
t.Errorf("no family filter matched %d teams, want all 3", got.Total)
}
// Combined with searchQuery: both must match.
if got := dir.SearchTeams(SearchRequest{Filters: SearchFilters{SearchQuery: "beta", Family: "cre-abt"}}); got.Total != 0 {
t.Errorf("searchQuery=beta AND family=cre-abt matched %d teams, want 0 (beta is sre-abt)", got.Total)
}
}

// A returned page must not alias the startup snapshot, or a caller appending to
// it would corrupt every later response.
func TestSearchTeams_PageDoesNotAliasTheSnapshot(t *testing.T) {
Expand Down
27 changes: 22 additions & 5 deletions apps/csm-portal/backend/internal/directory/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ type Pagination struct {
// SearchFilters holds the optional filter criteria both catalogues accept.
type SearchFilters struct {
SearchQuery string `json:"searchQuery,omitempty"`
// Family restricts POST /teams/search to teams whose TeamResult.Family
// exactly matches (case-insensitive) — e.g. "cre-abt" for the ABT
// dashboard's team picker. Ignored by SearchRoles. A team with no family
// configured never matches a non-empty filter.
Family string `json:"family,omitempty"`
}

// SearchRequest is the body of POST /teams/search and POST /roles/search.
Expand All @@ -46,9 +51,9 @@ type TeamResult struct {
ID string `json:"id"`
Name string `json:"name"`
// Family may be empty: not every team is classified into a family. It is
// what a discipline-scoped team picker would filter on (an SRE dashboard
// offering only sre-abt teams), but nothing filters on it yet -- the
// dashboard picker renders every team this endpoint returns.
// what SearchRequest.Filters.Family filters on -- a discipline-scoped
// picker (e.g. an SRE dashboard offering only sre-abt teams) requests it
// via the frontend's own dashboard-type -> family mapping.
Family string `json:"family,omitempty"`
// GroupID is the backing group's id in this platform's UUID form, suitable
// for the case-search integrationCsTeam filter. Omitted when the registry
Expand Down Expand Up @@ -80,8 +85,11 @@ type SearchRolesResponse struct {
}

// SearchTeams serves the team catalogue entirely from the startup-resolved
// index: no upstream call, on this request or any other. Matching is a
// case-insensitive substring of either the display name or the key.
// index: no upstream call, on this request or any other. SearchQuery matching
// is a case-insensitive substring of either the display name or the key;
// Family matching is a case-insensitive exact match against TeamResult.Family
// (a discipline-scoped picker, e.g. an SRE dashboard, passes "sre-abt" to
// exclude every other family, including teams with no family at all).
func (d *Directory) SearchTeams(req SearchRequest) SearchTeamsResponse {
teams := d.teamResults
if q := strings.TrimSpace(req.Filters.SearchQuery); q != "" {
Expand All @@ -95,6 +103,15 @@ func (d *Directory) SearchTeams(req SearchRequest) SearchTeamsResponse {
}
teams = filtered
}
if fam := strings.TrimSpace(req.Filters.Family); fam != "" {
filtered := make([]TeamResult, 0, len(teams))
for _, t := range teams {
if strings.EqualFold(t.Family, fam) {
filtered = append(filtered, t)
}
}
teams = filtered
}

total := len(teams)
offset, limit, length := clampCatalogPagination(req.Pagination, total)
Expand Down
66 changes: 31 additions & 35 deletions apps/csm-portal/backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5634,12 +5634,25 @@ components:

DeployedProductRef:
type: object
description: >-
A deployed product instance, together with the product catalogue entry it
was deployed from. The two are different records with different ids; they
often read alike because a catalogue name usually already embeds its
version. id and displayName are null when the case names a catalogue
product but no deployed instance of it.
properties:
id:
type: string
format: uuid
nullable: true
displayName:
type: string
nullable: true
description: Product name combined with version (e.g. "API Manager 4.3.0").
product:
nullable: true
$ref: '#/components/schemas/EntityRef'
description: Product catalogue entry this instance was deployed from.

CaseView:
type: object
Expand Down Expand Up @@ -5709,8 +5722,6 @@ components:
nullable: true
description: Timestamp when the case was closed. null if not yet closed.
createdBy:
$ref: '#/components/schemas/UserRef'
createdByUser:
nullable: true
allOf:
- $ref: '#/components/schemas/UserReference'
Expand All @@ -5724,10 +5735,11 @@ components:
nullable: true
allOf:
- $ref: '#/components/schemas/DeployedProductRef'
product:
nullable: true
allOf:
- $ref: '#/components/schemas/EntityRef'
description: |
Deployed product instance named by the case, together with the product
catalogue entry it was deployed from. A case can name a catalogue product
without naming a deployed instance of it, in which case this object is still
returned with id and displayName null and product populated.
catalog:
nullable: true
allOf:
Expand All @@ -5745,11 +5757,10 @@ components:
allOf:
- $ref: '#/components/schemas/EntityRef'
assignedEngineer:
$ref: '#/components/schemas/AssignedEngineerRef'
assignedEngineerUser:
nullable: true
allOf:
- $ref: '#/components/schemas/UserReference'
description: Engineer assigned to the case, or null when unassigned.
acknowledgedBy:
nullable: true
allOf:
Expand Down Expand Up @@ -5941,8 +5952,6 @@ components:
nullable: true
description: Timestamp when the case was closed. null if not yet closed.
createdBy:
$ref: '#/components/schemas/UserIDEmailRef'
createdByUser:
nullable: true
allOf:
- $ref: '#/components/schemas/UserReference'
Expand All @@ -5961,11 +5970,10 @@ components:
allOf:
- $ref: '#/components/schemas/EntityRef'
assignedEngineer:
$ref: '#/components/schemas/AssignedEngineerRef'
assignedEngineerUser:
nullable: true
allOf:
- $ref: '#/components/schemas/UserReference'
description: Engineer assigned to the case, or null when unassigned.
parentCase:
$ref: '#/components/schemas/CaseNumberRef'
relatedCase:
Expand Down Expand Up @@ -6150,10 +6158,9 @@ components:
Team family classification. The `-abt` variants are account-based teams; the
bare variants classify a member of the same discipline who is not on an
account-based team. Omitted when the team is not classified into a family:
not every team is. This is the field a discipline-scoped team picker *can*
filter on: it is what would let an SRE-scoped dashboard offer only
`sre-abt` teams. Nothing does so yet. Today's picker renders every team
this endpoint returns, and the scoping is a follow-up.
not every team is. This is the field a discipline-scoped team picker filters
on via `SearchTeamsRequest.filters.family` — an SRE-scoped dashboard's picker
requests `sre-abt` and gets only those teams back.
groupId:
type: string
description: >
Expand All @@ -6170,6 +6177,13 @@ components:
properties:
searchQuery:
type: string
family:
type: string
enum: [cre-abt, cre, sre-abt, sre]
description: >
Restrict results to teams whose family exactly matches (case-insensitive).
A team with no family configured never matches. Used by a discipline-scoped
picker, e.g. a `cre`-type dashboard's team selector passes `cre-abt`.
pagination:
$ref: '#/components/schemas/UserPagination'

Expand Down Expand Up @@ -7498,8 +7512,6 @@ components:
content:
type: string
createdBy:
type: string
createdByUser:
nullable: true
allOf:
- $ref: '#/components/schemas/UserReference'
Expand Down Expand Up @@ -7567,8 +7579,6 @@ components:
type:
type: string
createdBy:
type: string
createdByUser:
nullable: true
allOf:
- $ref: '#/components/schemas/UserReference'
Expand Down Expand Up @@ -7749,8 +7759,6 @@ components:
type: string
nullable: true
createdBy:
type: string
createdByUser:
nullable: true
allOf:
- $ref: '#/components/schemas/UserReference'
Expand Down Expand Up @@ -8775,22 +8783,10 @@ components:
format: date-time
description: Timestamp when the message was created.
createdBy:
type: object
description: Author of the message.
properties:
id:
type: string
description: User identifier (email).
firstName:
type: string
lastName:
type: string
fullName:
type: string
createdByUser:
nullable: true
allOf:
- $ref: '#/components/schemas/UserReference'
description: Author of the message.

ConversationMessagesResponse:
type: object
Expand Down
56 changes: 23 additions & 33 deletions apps/csm-portal/webapp/src/api/backend/mappers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// under the License.

import { describe, expect, it } from "vitest";
import type { BeCaseComment, BeComment } from "./types";
import type { BeComment } from "./types";
import {
beStateFromUi,
commentTypeFromInternal,
Expand Down Expand Up @@ -102,16 +102,15 @@ describe("commentTypeFromInternal", () => {
});

describe("uiCommentFromBe", () => {
const base: BeCaseComment = {
const base: BeComment = {
id: "c1",
caseId: "case1",
referenceId: "case1",
type: "comment",
content: "<p>hello</p>",
createdBy: {
id: "user@wso2.com",
firstName: "Ada",
lastName: "Lovelace",
fullName: "Ada Lovelace ⓦ",
id: null,
email: "user@wso2.com",
name: "Ada Lovelace ⓦ",
},
createdOn: "2026-06-01T10:00:00Z",
};
Expand Down Expand Up @@ -145,34 +144,34 @@ describe("uiCommentFromBe", () => {
expect(ui.bodyHtml).toBe('<p>a &amp; b</p><img src=x onerror="alert(1)">');
});

it("falls back from fullName to first+last, then id", () => {
it("falls back from name to email, then Unknown when createdBy is null", () => {
expect(
uiCommentFromBe({
...base,
createdBy: { id: "x@wso2.com", firstName: "Grace", lastName: "Hopper" },
createdBy: { id: null, email: "x@wso2.com", name: "" },
}).authorName,
).toBe("Grace Hopper");
expect(
uiCommentFromBe({ ...base, createdBy: { id: "x@wso2.com" } }).authorName,
).toBe("x@wso2.com");
expect(
uiCommentFromBe({ ...base, createdBy: null }).authorName,
).toBe("Unknown");
});
});

describe("uiCommentFromBe — /comments/search shape and chat", () => {
// The confirmed shape backing both case comments and chat messages: a nested
// `createdBy` object, `referenceId` (not `caseId`), and a normalized singular
// `type`. (createdOn tie-break etc. is covered in caseActivityFeed.test.ts.)
// The confirmed shape backing both case comments and chat messages: a
// canonical `createdBy` UserReference, `referenceId` (not `caseId`), and a
// normalized singular `type`. (createdOn tie-break etc. is covered in
// caseActivityFeed.test.ts.)
const msg: BeComment = {
id: "m1",
referenceId: "conv1",
content: "the EOL for AWS RDS MySQL 8.0.42 is July 31st, 2026",
type: "comment",
createdOn: "2026-07-01T00:51:54Z",
createdBy: {
id: "sree@abc.com",
firstName: "Sree",
lastName: "Kumar",
fullName: "Sree Kumar",
id: null,
email: "sree@abc.com",
name: "Sree Kumar",
},
};

Expand All @@ -184,18 +183,9 @@ describe("uiCommentFromBe — /comments/search shape and chat", () => {
expect(ui.caseId).toBe("conv1"); // referenceId, not caseId
});

it("detects Novera as a chatbot via the nested createdBy.id", () => {
const ui = uiCommentFromBe(
{ ...msg, createdBy: { id: "Novera", fullName: "Novera" } },
{ context: "conversation" },
);
expect(ui.authorRole).toBe("chatbot");
});

it("detects Novera via nested createdBy.fullName when the id is opaque", () => {
// The field the settled BE payload actually carries the bot name in.
it("detects Novera as a chatbot via the nested createdBy.name", () => {
const ui = uiCommentFromBe(
{ ...msg, createdBy: { id: "svc-account-9f2c", fullName: "Novera" } },
{ ...msg, createdBy: { id: null, email: "novera@bot", name: "Novera" } },
{ context: "conversation" },
);
expect(ui.authorRole).toBe("chatbot");
Expand All @@ -211,11 +201,11 @@ describe("uiCommentFromBe — /comments/search shape and chat", () => {
expect(ui.authorRole).toBe("wso2_engineer");
});

it("uses the bare createdBy string from the comment-create ack", () => {
it("falls back to Unknown when createdBy is null", () => {
const ui = uiCommentFromBe(
{ ...msg, createdBy: "someone@wso2.com" },
{ ...msg, createdBy: null },
{ context: "case" },
);
expect(ui.authorName).toBe("someone@wso2.com");
expect(ui.authorName).toBe("Unknown");
});
});
Loading