fix(workbench): keep active notices after reading
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
fetchOperationalTaskNotifications,
|
||||||
parseOperationalTask,
|
parseOperationalTask,
|
||||||
parseOperationalTaskNotification
|
parseOperationalTaskNotification
|
||||||
} from "./operational-task-client";
|
} from "./operational-task-client";
|
||||||
@@ -105,4 +106,30 @@ describe("operational notification contract", () => {
|
|||||||
resolution_reason: "action_approved"
|
resolution_reason: "action_approved"
|
||||||
}).lifecycle_status).toBe("resolved");
|
}).lifecycle_status).toBe("resolved");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requests the active inbox instead of hiding already read work", async () => {
|
||||||
|
let request: Request | undefined;
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async (input, init) => {
|
||||||
|
request = new Request(input, init);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
items: [{
|
||||||
|
...notification,
|
||||||
|
lifecycle_status: "active",
|
||||||
|
status: "read",
|
||||||
|
version: 2,
|
||||||
|
read_at: time,
|
||||||
|
read_by: `principal:${id}`
|
||||||
|
}],
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
next_offset: null
|
||||||
|
}), { status: 200 });
|
||||||
|
}));
|
||||||
|
|
||||||
|
const items = await fetchOperationalTaskNotifications();
|
||||||
|
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0]?.status).toBe("read");
|
||||||
|
expect(new URL(request!.url).searchParams.get("unread_only")).toBe("false");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -397,18 +397,18 @@ export async function decideOperationalTaskAccess(taskId: string, accessRequestI
|
|||||||
|
|
||||||
export async function fetchOperationalTaskNotifications(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
export async function fetchOperationalTaskNotifications(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
||||||
return notificationPageSchema.parse(await operationalTaskRequest(
|
return notificationPageSchema.parse(await operationalTaskRequest(
|
||||||
"/operational-task-notifications?unread_only=true&limit=100&offset=0",
|
"/operational-task-notifications?unread_only=false&limit=100&offset=0",
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
{ signal }
|
{ signal }
|
||||||
)).items;
|
)).items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function acknowledgeOperationalTaskNotification(notificationId: string, version: number, requestId: string, getAccessToken?: AccessTokenProvider) {
|
export async function acknowledgeOperationalTaskNotification(notificationId: string, version: number, requestId: string, getAccessToken?: AccessTokenProvider) {
|
||||||
await operationalTaskRequest(
|
return notificationSchema.parse(await operationalTaskRequest(
|
||||||
`/operational-task-notifications/${encodeURIComponent(notificationId)}/acknowledge`,
|
`/operational-task-notifications/${encodeURIComponent(notificationId)}/acknowledge`,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
{ method: "POST", body: JSON.stringify({ request_id: requestId, expected_version: version }) }
|
{ method: "POST", body: JSON.stringify({ request_id: requestId, expected_version: version }) }
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOperationalTaskSchedules(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
export async function fetchOperationalTaskSchedules(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { OperationalTask } from "../api/operational-task-client";
|
|||||||
import type { WorkbenchEvent } from "../api/workbench-sync-client";
|
import type { WorkbenchEvent } from "../api/workbench-sync-client";
|
||||||
import {
|
import {
|
||||||
collectOperationalTaskEventChanges,
|
collectOperationalTaskEventChanges,
|
||||||
|
selectHeaderNotifications,
|
||||||
toOperationalTaskCondition
|
toOperationalTaskCondition
|
||||||
} from "./use-operational-tasks";
|
} from "./use-operational-tasks";
|
||||||
|
|
||||||
@@ -123,6 +124,37 @@ describe("operational task event invalidation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("operational notification visibility", () => {
|
||||||
|
it("keeps a read notification visible until its business lifecycle is resolved", () => {
|
||||||
|
const notification = {
|
||||||
|
notification_id: "66666666-6666-4666-8666-666666666666",
|
||||||
|
task_id: "22222222-2222-4222-8222-222222222222",
|
||||||
|
kind: "action_approval_pending" as const,
|
||||||
|
title: "待审批工单",
|
||||||
|
description: "请审核派单内容",
|
||||||
|
required_permission: null,
|
||||||
|
lifecycle_status: "active" as const,
|
||||||
|
subject_type: "action" as const,
|
||||||
|
subject_id: "77777777-7777-4777-8777-777777777777",
|
||||||
|
resolved_at: null,
|
||||||
|
resolution_reason: null,
|
||||||
|
status: "read" as const,
|
||||||
|
version: 2,
|
||||||
|
created_at: "2026-09-24T00:00:00Z",
|
||||||
|
read_at: "2026-09-24T00:01:00Z",
|
||||||
|
read_by: "principal:11111111-1111-4111-8111-111111111111"
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(selectHeaderNotifications([notification])).toEqual([notification]);
|
||||||
|
expect(selectHeaderNotifications([{
|
||||||
|
...notification,
|
||||||
|
lifecycle_status: "resolved",
|
||||||
|
resolved_at: "2026-09-24T00:02:00Z",
|
||||||
|
resolution_reason: "action_approved"
|
||||||
|
}])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function event(
|
function event(
|
||||||
eventSequence: number,
|
eventSequence: number,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export function collectOperationalTaskEventChanges(events: WorkbenchEvent[]) {
|
|||||||
return { taskIds, deletedTaskIds, notificationsChanged };
|
return { taskIds, deletedTaskIds, notificationsChanged };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectHeaderNotifications(items: OperationalTaskNotification[]) {
|
||||||
|
return items.filter((item) => item.lifecycle_status === "active");
|
||||||
|
}
|
||||||
|
|
||||||
export function useOperationalTasks(
|
export function useOperationalTasks(
|
||||||
getAccessToken: AccessTokenProvider | undefined,
|
getAccessToken: AccessTokenProvider | undefined,
|
||||||
sync: WorkbenchSyncState
|
sync: WorkbenchSyncState
|
||||||
@@ -106,9 +110,7 @@ export function useOperationalTasks(
|
|||||||
forceRefreshRef.current = false;
|
forceRefreshRef.current = false;
|
||||||
detailResetGenerationRef.current = sync.detailResetGeneration;
|
detailResetGenerationRef.current = sync.detailResetGeneration;
|
||||||
setTasks(nextTasks);
|
setTasks(nextTasks);
|
||||||
setNotifications((sync.snapshot?.notifications ?? []).filter(
|
setNotifications(selectHeaderNotifications(sync.snapshot?.notifications ?? []));
|
||||||
(item) => item.lifecycle_status === "active" && item.status === "unread"
|
|
||||||
));
|
|
||||||
setAvailable(true);
|
setAvailable(true);
|
||||||
activatedRef.current = true;
|
activatedRef.current = true;
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -283,13 +285,15 @@ export function useOperationalTasks(
|
|||||||
}, [getAccessToken, refresh]);
|
}, [getAccessToken, refresh]);
|
||||||
|
|
||||||
const acknowledgeNotification = useCallback(async (notificationId: string, version: number) => {
|
const acknowledgeNotification = useCallback(async (notificationId: string, version: number) => {
|
||||||
await acknowledgeOperationalTaskNotification(
|
const updated = await acknowledgeOperationalTaskNotification(
|
||||||
notificationId,
|
notificationId,
|
||||||
version,
|
version,
|
||||||
createOperationalRequestId(),
|
createOperationalRequestId(),
|
||||||
getAccessToken
|
getAccessToken
|
||||||
);
|
);
|
||||||
setNotifications((current) => current.filter((item) => item.notification_id !== notificationId));
|
setNotifications((current) => selectHeaderNotifications(current.map((item) =>
|
||||||
|
item.notification_id === notificationId ? updated : item
|
||||||
|
)));
|
||||||
}, [getAccessToken]);
|
}, [getAccessToken]);
|
||||||
|
|
||||||
const loadEvidenceSnapshot = useCallback((taskId: string, evidenceId: string) =>
|
const loadEvidenceSnapshot = useCallback((taskId: string, evidenceId: string) =>
|
||||||
|
|||||||
@@ -766,7 +766,9 @@ export function MapWorkbenchPage({
|
|||||||
if (alert.id.startsWith("operational-task-notification:")) {
|
if (alert.id.startsWith("operational-task-notification:")) {
|
||||||
const notificationId = alert.id.slice("operational-task-notification:".length);
|
const notificationId = alert.id.slice("operational-task-notification:".length);
|
||||||
const notification = operationalTasks.notifications.find((item) => item.notification_id === notificationId);
|
const notification = operationalTasks.notifications.find((item) => item.notification_id === notificationId);
|
||||||
if (notification) void operationalTasks.acknowledgeNotification(notificationId, notification.version);
|
if (notification?.status === "unread") {
|
||||||
|
void operationalTasks.acknowledgeNotification(notificationId, notification.version);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const conditionId = alert.conditionId ?? getScheduledConditionIdFromAlertId(alert.id);
|
const conditionId = alert.conditionId ?? getScheduledConditionIdFromAlertId(alert.id);
|
||||||
if (conditionId) {
|
if (conditionId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user