Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
d58e8e3c6a fix(inbox): keep stale selection on /inbox instead of the deleted issue
When another tab deletes the selected inbox issue, onInboxIssueDeleted
prunes the cache and `selected` becomes null. The existing fallback then
redirected to the issue detail page — which is also gone, so the user
landed on a "This issue does not exist..." screen instead of back in the
inbox list.

Track the last key that actually resolved against the inbox list. If it
used to be in the list and just disappeared, clear the selection and
stay on /inbox. Only shared links that were never in the user's inbox
continue to fall back to the issue detail page.

Also add ws-updaters tests covering onInboxIssueDeleted and
onInboxIssueStatusChanged.
2026-04-21 17:09:24 +08:00
Jiang Bohan
990e498d70 fix(inbox): don't archive after deleting an issue
Deleting an issue from the Inbox page was calling the archive API on the
inbox item right after deleteIssue succeeded. Because the inbox_item row
has ON DELETE CASCADE on issue_id, it was already gone by then and the
archive call 404'd with "inbox item not found", surfacing a "Failed to
archive" toast.

Drop the redundant archive call and invalidate the inbox cache through
the issue:deleted WS handler so every tab stays in sync without an extra
round trip.
2026-04-21 16:59:09 +08:00
4 changed files with 122 additions and 12 deletions

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { onInboxIssueDeleted, onInboxIssueStatusChanged } from "./ws-updaters";
import { inboxKeys } from "./queries";
import type { InboxItem } from "../types";
const wsId = "ws-1";
function makeItem(
id: string,
issueId: string | null,
overrides: Partial<InboxItem> = {},
): InboxItem {
return {
id,
workspace_id: wsId,
recipient_type: "member",
recipient_id: "user-1",
actor_type: null,
actor_id: null,
type: "mentioned",
severity: "info",
issue_id: issueId,
title: `item ${id}`,
body: null,
issue_status: null,
read: false,
archived: false,
created_at: "2025-01-01T00:00:00Z",
details: null,
...overrides,
};
}
describe("onInboxIssueDeleted", () => {
it("removes all inbox items referencing the deleted issue", () => {
const qc = new QueryClient();
const items = [
makeItem("i1", "issue-a"),
makeItem("i2", "issue-a"),
makeItem("i3", "issue-b"),
makeItem("i4", null),
];
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), items);
onInboxIssueDeleted(qc, wsId, "issue-a");
const after = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
expect(after?.map((i) => i.id)).toEqual(["i3", "i4"]);
});
it("is a no-op when the inbox cache is empty", () => {
const qc = new QueryClient();
expect(() => onInboxIssueDeleted(qc, wsId, "issue-a")).not.toThrow();
expect(qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId))).toBeUndefined();
});
});
describe("onInboxIssueStatusChanged", () => {
it("updates issue_status only for items referencing the issue", () => {
const qc = new QueryClient();
const items = [
makeItem("i1", "issue-a", { issue_status: "todo" }),
makeItem("i2", "issue-b", { issue_status: "todo" }),
];
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), items);
onInboxIssueStatusChanged(qc, wsId, "issue-a", "done");
const after = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
expect(after?.find((i) => i.id === "i1")?.issue_status).toBe("done");
expect(after?.find((i) => i.id === "i2")?.issue_status).toBe("todo");
});
});

View File

@@ -25,6 +25,19 @@ export function onInboxIssueStatusChanged(
);
}
// Mirrors the DB-level ON DELETE CASCADE on inbox_item.issue_id: when an issue
// is deleted, all inbox items that referenced it are gone server-side, so drop
// them from the cache too.
export function onInboxIssueDeleted(
qc: QueryClient,
wsId: string,
issueId: string,
) {
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.filter((i) => i.issue_id !== issueId),
);
}
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
}

View File

@@ -19,7 +19,7 @@ import {
onIssueUpdated,
onIssueDeleted,
} from "../issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "../inbox/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
import { chatKeys } from "../chat/queries";
@@ -186,7 +186,10 @@ export function useRealtimeSync(
const { issue_id } = p as IssueDeletedPayload;
if (!issue_id) return;
const wsId = getCurrentWsId();
if (wsId) onIssueDeleted(qc, wsId, issue_id);
if (wsId) {
onIssueDeleted(qc, wsId, issue_id);
onInboxIssueDeleted(qc, wsId, issue_id);
}
});
const unsubInboxNew = ws.on("inbox:new", (p) => {

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -67,15 +67,14 @@ export function InboxPage() {
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
// Shared inbox links (?issue=<id>) may point to notifications not in this
// user's inbox (archived, or never received). Fall back to the issue page
// so the URL still resolves to something meaningful.
// Track the last key we actually resolved against the inbox list. Lets the
// fallback effect distinguish "shared-link to a notification not in our
// inbox" (never resolved → redirect to the issue page) from "item was in
// our inbox and just got removed" (was resolved → stay on /inbox).
const lastResolvedKeyRef = useRef<string>("");
useEffect(() => {
if (loading) return;
if (!selectedKey) return;
if (selected) return;
replace(wsPaths.issueDetail(selectedKey));
}, [loading, selectedKey, selected, replace, wsPaths]);
if (selected) lastResolvedKeyRef.current = selectedKey;
}, [selected, selectedKey]);
const setSelectedKey = useCallback((key: string) => {
setSelectedKeyState(key);
@@ -84,6 +83,23 @@ export function InboxPage() {
replace(url);
}, [replace, wsPaths]);
// Shared inbox links (?issue=<id>) may point to notifications not in this
// user's inbox (archived, or never received). Fall back to the issue page
// so the URL still resolves to something meaningful. But if the key was
// previously resolvable (e.g. the issue was just deleted in another tab
// and `onInboxIssueDeleted` pruned the cache), the issue detail would 404
// too — clear the selection and stay on /inbox instead.
useEffect(() => {
if (loading) return;
if (!selectedKey) return;
if (selected) return;
if (lastResolvedKeyRef.current === selectedKey) {
setSelectedKey("");
return;
}
replace(wsPaths.issueDetail(selectedKey));
}, [loading, selectedKey, selected, replace, wsPaths, setSelectedKey]);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_inbox_layout",
});
@@ -223,7 +239,11 @@ export function InboxPage() {
layoutId="multica_inbox_issue_detail_layout"
highlightCommentId={selected.details?.comment_id ?? undefined}
onDelete={() => {
handleArchive(selected.id);
// Issue deletion CASCADE-deletes the inbox item server-side, and the
// issue:deleted WS event prunes it from the inbox cache. Just clear
// the selection — calling archive here would 404 on a row that no
// longer exists.
setSelectedKey("");
}}
/>
) : selected ? (