Compare commits

...

1 Commits

Author SHA1 Message Date
J
6f65d5f235 fix(core): scope inbox notification mute check to the source workspace
Follow-up to #3797. The inbox:new handler keys the notification-preference
query on item.workspace_id, but the request itself still resolved its
workspace from the active-workspace X-Workspace-Slug header. On a cold
cache, a user viewing workspace B who received a workspace-A notification
read B's mute setting and cached it under A's key — so A's banners could
fire while muted (and vice-versa), polluting A's cache.

Add an optional workspaceSlug override to getNotificationPreferences and
notificationPreferenceOptions, and pass the resolved source slug from the
inbox:new handler. When the source slug can't be resolved, read only an
already-warm cache instead of fetching with the wrong workspace. Tests
cover the cold-cache source-slug fetch, source mute suppression, and the
no-fallback guard.

MUL-3062

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 15:59:56 +08:00
4 changed files with 116 additions and 21 deletions

View File

@@ -1355,8 +1355,16 @@ export class ApiClient {
}
// Notification preferences
async getNotificationPreferences(): Promise<NotificationPreferenceResponse> {
return this.fetch("/api/notification-preferences");
//
// `workspaceSlug` overrides the default `X-Workspace-Slug` header (which
// follows the active workspace) so a caller can read a SPECIFIC workspace's
// preferences — e.g. honoring the mute setting of the workspace an inbox
// notification came from while the user is viewing a different one (#3766).
async getNotificationPreferences(workspaceSlug?: string): Promise<NotificationPreferenceResponse> {
return this.fetch(
"/api/notification-preferences",
workspaceSlug ? { headers: { "X-Workspace-Slug": workspaceSlug } } : undefined,
);
}
async updateNotificationPreferences(preferences: NotificationPreferences): Promise<NotificationPreferenceResponse> {

View File

@@ -5,9 +5,16 @@ export const notificationPreferenceKeys = {
all: (wsId: string) => ["notification-preferences", wsId] as const,
};
export function notificationPreferenceOptions(wsId: string) {
/**
* `workspaceSlug` scopes the underlying request to a specific workspace via
* the `X-Workspace-Slug` override. The query key stays keyed on `wsId` (slug
* and id are 1:1), so the cache entry is still per-workspace; the slug only
* ensures a cold-cache fetch reads the intended workspace rather than the
* active one. Omit it to follow the active workspace (the Settings page).
*/
export function notificationPreferenceOptions(wsId: string, workspaceSlug?: string) {
return queryOptions({
queryKey: notificationPreferenceKeys.all(wsId),
queryFn: () => api.getNotificationPreferences(),
queryFn: () => api.getNotificationPreferences(workspaceSlug),
});
}

View File

@@ -1,5 +1,7 @@
import { QueryClient, type InfiniteData } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setApiInstance } from "../api";
import type { ApiClient } from "../api/client";
import { chatKeys } from "../chat/queries";
import { inboxKeys } from "../inbox/queries";
import { issueKeys } from "../issues/queries";
@@ -399,4 +401,71 @@ describe("handleInboxNew", () => {
expect(showNotification).not.toHaveBeenCalled();
});
// The tests below exercise the COLD-cache mute path (source preference not
// yet cached), where the request — not just the query key — must be scoped
// to the source workspace (#3766 follow-up). They install a fake API so the
// outgoing call's workspace argument is observable.
afterEach(() => {
setApiInstance(undefined as unknown as ApiClient);
});
it("fetches the SOURCE workspace's preference using its slug when the cache is cold", async () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ id: "ws-b", slug: "workspace-b", name: "Workspace B" }),
workspace(),
]);
// No cached preference for ws-a → the handler must fetch, and the fetch
// must target the source workspace's slug, not the active workspace's.
const getNotificationPreferences = vi
.fn()
.mockResolvedValue({ preferences: { system_notifications: "all" } });
setApiInstance({ getNotificationPreferences } as unknown as ApiClient);
const showNotification = stubDesktopAPI();
await handleInboxNew(qc, inboxItem());
expect(getNotificationPreferences).toHaveBeenCalledWith("workspace-a");
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ slug: "workspace-a" }),
);
});
it("suppresses the banner when the SOURCE workspace is muted on a cold cache", async () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [workspace()]);
const getNotificationPreferences = vi
.fn()
.mockResolvedValue({ preferences: { system_notifications: "muted" } });
setApiInstance({ getNotificationPreferences } as unknown as ApiClient);
const showNotification = stubDesktopAPI();
await handleInboxNew(qc, inboxItem());
expect(getNotificationPreferences).toHaveBeenCalledWith("workspace-a");
expect(showNotification).not.toHaveBeenCalled();
});
it("never fetches the active workspace's preference when the source slug can't be resolved", async () => {
const qc = createQueryClient();
// Item's workspace is absent from the cached list → slug unresolvable.
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ id: "ws-b", slug: "workspace-b" }),
]);
const getNotificationPreferences = vi
.fn()
.mockResolvedValue({ preferences: { system_notifications: "muted" } });
setApiInstance({ getNotificationPreferences } as unknown as ApiClient);
const showNotification = stubDesktopAPI();
await handleInboxNew(qc, inboxItem());
// Must NOT fall back to the active workspace's preference — that both
// mis-mutes and pollutes the source workspace's cache key (#3766).
expect(getNotificationPreferences).not.toHaveBeenCalled();
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ slug: "" }),
);
});
});

View File

@@ -32,7 +32,10 @@ import {
} from "../issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
import { notificationPreferenceOptions } from "../notification-preferences/queries";
import {
notificationPreferenceOptions,
notificationPreferenceKeys,
} from "../notification-preferences/queries";
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
import type { Workspace } from "../types/workspace";
import { chatKeys } from "../chat/queries";
@@ -50,6 +53,7 @@ import type {
IssueMetadataChangedPayload,
InboxNewPayload,
InboxItem,
NotificationPreferenceResponse,
CommentCreatedPayload,
CommentUpdatedPayload,
CommentDeletedPayload,
@@ -220,29 +224,36 @@ export async function handleInboxNew(
// styling is enough — no need to interrupt with a banner. `desktopAPI`
// is injected by the preload script; its absence (web app) skips silently.
if (typeof document !== "undefined" && document.hasFocus()) return;
// Respect the SOURCE workspace's system-notification preference. The
// Settings page owns the only `useQuery` for this resource, so the cache
// may be cold for a workspace whose Settings page was never visited —
// `ensureQueryData` resolves from cache when present and otherwise
// fetches once. Keying this on the active workspace would let a banner
// fire for a muted workspace (and vice-versa). On network failure we
// fall through to the default ("all") rather than swallow the banner.
// Resolve the source workspace's slug once: it pins BOTH the mute check
// and the deep link to the workspace the inbox item BELONGS to, never the
// currently active one. Reading `getCurrentSlug()` here was the source of
// wrong-workspace routing (#3766): an `inbox:new` from workspace A arriving
// while workspace B is active emitted a notification carrying B's slug and
// A's issue id, deep-linking to an issue B doesn't have.
const slug = await resolveInboxSourceSlug(qc, sourceWsId);
// Respect the SOURCE workspace's system-notification preference. Keying the
// query on `sourceWsId` is not enough: the request resolves its workspace
// from the `X-Workspace-Slug` header, which follows the ACTIVE workspace —
// so a cold-cache lookup while viewing B would read B's mute setting and
// cache it under A's key. Passing the source slug scopes the fetch to A.
// When the slug can't be resolved we read only an already-warm cache
// (populated earlier with the correct workspace context) rather than fetch
// with the wrong one; on network failure we fall through to the default
// ("all") rather than swallow the banner.
if (sourceWsId) {
try {
const prefData = await qc.ensureQueryData(
notificationPreferenceOptions(sourceWsId),
);
const prefData = slug
? await qc.ensureQueryData(
notificationPreferenceOptions(sourceWsId, slug),
)
: qc.getQueryData<NotificationPreferenceResponse>(
notificationPreferenceKeys.all(sourceWsId),
);
if (prefData?.preferences?.system_notifications === "muted") return;
} catch {
// Fall through with default behavior.
}
}
// Route the click to the workspace the inbox item BELONGS to — not the
// currently active one. Reading `getCurrentSlug()` here was the source
// of wrong-workspace routing (#3766): an `inbox:new` from workspace A
// arriving while workspace B is active emitted a notification carrying
// B's slug and A's issue id, deep-linking to an issue B doesn't have.
const slug = await resolveInboxSourceSlug(qc, sourceWsId);
const desktopAPI = (
globalThis as unknown as {
desktopAPI?: {