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
48 changes: 48 additions & 0 deletions apps/csm-portal/webapp/src/api/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,54 @@ export interface BeCommentSearchResponse extends BeSearchResponseBase {
comments?: BeComment[];
}

// ---------------------------------------------------------------------------
// Case activities (unified comment / attachment / field-change stream)
// ---------------------------------------------------------------------------

/** One field changed within a single audited save-transaction. */
export interface BeFieldChange {
/** Wire field name (e.g. `state`, `priority`, `assignedEngineer`). */
field: string;
/** Human-readable label for the field (e.g. "State", "Severity"). */
fieldLabel: string;
/** Absent/empty when the field was previously unset. */
previousValue?: string;
/** Absent/empty when the field was cleared. */
newValue?: string;
}

export type BeCaseActivityType = "comment" | "attachment" | "field_change";

/**
* One entry from `POST /cases/{id}/activities/search`. Shared fields are
* present on every entry regardless of `type`; `changes` is populated only
* for `type === "field_change"`. This endpoint intentionally excludes work
* notes — the comments/work-notes feed continues to read from
* `/cases/{id}/comments/search` (see {@link BeComment}).
*/
export interface BeCaseActivityEntry {
id: string;
type: BeCaseActivityType;
content?: string;
createdOn: string;
createdBy?: string;
createdByFirstName?: string;
createdByLastName?: string;
createdByFullName?: string;
/** Only present on `type === "field_change"` entries. */
changes?: BeFieldChange[];
}

export interface BeCaseActivitiesSearchPayload {
pagination?: BePagination;
/** Whether the response should include `field_change` entries. */
includeFieldChanges?: boolean;
}

export interface BeCaseActivitiesSearchResponse extends BeSearchResponseBase {
activity?: BeCaseActivityEntry[];
}

// ---------------------------------------------------------------------------
// Attachments
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions apps/csm-portal/webapp/src/constants/apiConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export const ApiQueryKeys = {
CSM_CASE_DETAIL: "csm-case-detail",
CSM_CASE_COMMENTS: "csm-case-comments",
CSM_CASE_ATTACHMENTS: "csm-case-attachments",
CSM_CASE_ACTIVITIES: "csm-case-activities",
CSM_CASE_SLAS: "csm-case-slas",
CSM_PROJECTS: "csm-projects",
CSM_PROJECT_DETAIL: "csm-project-detail",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// 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.

import { describe, expect, it, vi } from "vitest";
import type { BeCaseActivityEntry } from "@api/backend/types";

// The module also exports `useGetCsmCaseActivities`, which imports the
// backend client; that client throws at import time if the runtime config
// (`CSM_PORTAL_BACKEND_BASE_URL`) isn't present, which it isn't under
// vitest. Stub it out — this test only exercises the pure mapper.
vi.mock("@api/backend/client", () => ({
useBackendApi: vi.fn(),
}));

const { auditEntryFromBeActivity } = await import("./useCsmCaseActivities");

describe("auditEntryFromBeActivity", () => {
it("maps a field_change entry with a preferred display-name order", () => {
const entry: BeCaseActivityEntry = {
id: "fc-1",
type: "field_change",
createdOn: "2026-07-01T00:00:00Z",
createdBy: "jane.doe@example.com",
createdByFirstName: "Jane",
createdByLastName: "Doe",
createdByFullName: "Jane Doe",
changes: [
{
field: "state",
fieldLabel: "State",
previousValue: "In Progress",
newValue: "Resolved",
},
],
};

const mapped = auditEntryFromBeActivity(entry);

expect(mapped).toEqual({
id: "fc-1",
kind: "field_change",
actor: "Jane Doe",
createdAt: "2026-07-01T00:00:00Z",
changes: [
{
field: "state",
fieldLabel: "State",
previousValue: "In Progress",
newValue: "Resolved",
},
],
});
});

it("falls back to first+last name, then the bare email, when fullName is absent", () => {
const noFullName: BeCaseActivityEntry = {
id: "fc-2",
type: "field_change",
createdOn: "2026-07-01T00:00:00Z",
createdByFirstName: "Jane",
createdByLastName: "Doe",
changes: [],
};
expect(auditEntryFromBeActivity(noFullName).actor).toBe("Jane Doe");

const emailOnly: BeCaseActivityEntry = {
id: "fc-3",
type: "field_change",
createdOn: "2026-07-01T00:00:00Z",
createdBy: "jane.doe@example.com",
changes: [],
};
expect(auditEntryFromBeActivity(emailOnly).actor).toBe(
"jane.doe@example.com",
);

const nothing: BeCaseActivityEntry = {
id: "fc-4",
type: "field_change",
createdOn: "2026-07-01T00:00:00Z",
changes: [],
};
expect(auditEntryFromBeActivity(nothing).actor).toBe("Unknown");
});

it("defaults changes to an empty array when absent", () => {
const entry: BeCaseActivityEntry = {
id: "fc-5",
type: "field_change",
createdOn: "2026-07-01T00:00:00Z",
createdByFullName: "Jane Doe",
};
expect(auditEntryFromBeActivity(entry).changes).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// 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.

import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { ApiQueryKeys, BE_MAX_PAGE_LIMIT } from "@constants/apiConstants";
import { useBackendApi } from "@api/backend/client";
import type {
BeCaseActivitiesSearchPayload,
BeCaseActivitiesSearchResponse,
BeCaseActivityEntry,
} from "@api/backend/types";
import type { CaseAuditEntry } from "@features/csm-cases/types/csmCases";

/** Page size used when loading the field-change lane. Capped by the BE; see BE_MAX_PAGE_LIMIT. */
const ACTIVITIES_PAGE_LIMIT = BE_MAX_PAGE_LIMIT;

/** Best display name off an activity entry's flat author fields. */
function activityAuthorName(entry: BeCaseActivityEntry): string {
const full = entry.createdByFullName?.trim();
if (full) return full;
const composed = [entry.createdByFirstName, entry.createdByLastName]
.filter((p) => p && p.trim())
.join(" ")
.trim();
if (composed) return composed;
return entry.createdBy?.trim() || "Unknown";
}

/** Map one backend `field_change` activity entry onto a {@link CaseAuditEntry}. */
export function auditEntryFromBeActivity(
entry: BeCaseActivityEntry,
): CaseAuditEntry {
return {
id: entry.id,
kind: "field_change",
actor: activityAuthorName(entry),
createdAt: entry.createdOn,
changes: (entry.changes ?? []).map((c) => ({
field: c.field,
fieldLabel: c.fieldLabel,
previousValue: c.previousValue,
newValue: c.newValue,
})),
};
}

/**
* Load the audited field/state-change lane for a case. In LIVE mode calls
* `POST /cases/{id}/activities/search` with a single wide page (limit capped
* at BE_MAX_PAGE_LIMIT) and `includeFieldChanges: true`, then filters the
* response down to `type === "field_change"` entries — this endpoint also
* returns `comment`/`attachment` entries, but those lanes keep reading from
* their existing hooks (`useGetCsmCaseComments` / `useGetCsmCaseAttachments`),
* so they are ignored here to avoid a second, divergent read path. Notably
* this endpoint excludes work notes, so it must never replace the comments
* hook.
*/
export function useGetCsmCaseActivities(
caseId: string | undefined,
): UseQueryResult<CaseAuditEntry[], Error> {
const api = useBackendApi();

return useQuery<CaseAuditEntry[], Error>({
queryKey: [ApiQueryKeys.CSM_CASE_ACTIVITIES, caseId ?? ""],
queryFn: async (): Promise<CaseAuditEntry[]> => {
if (!caseId) return [];

const payload: BeCaseActivitiesSearchPayload = {
pagination: { offset: 0, limit: ACTIVITIES_PAGE_LIMIT },
includeFieldChanges: true,
};
const response = await api.post<
BeCaseActivitiesSearchPayload,
BeCaseActivitiesSearchResponse
>(`/cases/${encodeURIComponent(caseId)}/activities/search`, payload);
return (response.activity ?? [])
.filter((a) => a.type === "field_change")
.map(auditEntryFromBeActivity);
},
enabled: !!caseId,
staleTime: 10_000,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export function usePatchCsmCase(
queryKey: [ApiQueryKeys.CSM_CASE_DETAIL, caseId ?? ""],
});
queryClient.invalidateQueries({ queryKey: [ApiQueryKeys.CSM_CASES] });
// A state/severity/assignee/watcher patch is audited server-side, so
// refresh the activity/field-change lane too — otherwise the new
// lifecycle entry wouldn't show until the next unrelated refetch.
queryClient.invalidateQueries({
queryKey: [ApiQueryKeys.CSM_CASE_ACTIVITIES, caseId ?? ""],
});
},
});
}
Expand Down Expand Up @@ -86,6 +92,9 @@ export function usePatchCsmCaseById(): (
queryKey: [ApiQueryKeys.CSM_CASE_DETAIL, caseId],
});
queryClient.invalidateQueries({ queryKey: [ApiQueryKeys.CSM_CASES] });
queryClient.invalidateQueries({
queryKey: [ApiQueryKeys.CSM_CASE_ACTIVITIES, caseId],
});
},
[api, queryClient],
);
Expand Down
Loading