mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
Adds an "Archived" sub-view to the Inbox, reachable from an entry at the
bottom of the main list, with per-row unarchive. Mirrors chat's archived
sub-view so the two surfaces share one mental model.
Backend:
- GET /api/inbox/archived and POST /api/inbox/{id}/unarchive. Kept off the
existing GET /api/inbox so installed clients keep their contract and the
unbounded archive never rides along with the main list.
- The archived query excludes any issue that still has an active row. Archiving
is issue-level, so a new notification on an archived issue leaves old archived
rows beside a fresh active one — without the guard the issue renders in BOTH
lists. The exclusion lives in SQL so neither list depends on the other's cache.
- Unarchive is issue-level (mirroring archive) and leaves `read` untouched, so a
restored unread item raises the unread badge again.
- v1 ships no pagination: LIMIT 200, newest-first, so truncation drops the
oldest rows and never hides a group's newest one.
- inbox:unarchived event, fanned out to the recipient like the other personal
inbox events.
- Two CONCURRENTLY-built indexes; inbox_item previously had none covering
workspace/archived/created_at.
Frontend:
- Separate TanStack cache per list; every inbox event invalidates the workspace
prefix, since any of them can move an item across the boundary.
- View persisted as ?view=archived, so refresh, back/forward, and the mobile
detail-back all return to the list the user was in.
- Batch actions stay main-view only — they archive from the MAIN inbox, so
offering them over the archived list would do the opposite of what it reads.
- Mobile subscribes to inbox:unarchived (its list gains the restored row); its
own archived view remains follow-up.
Known debt: no pagination, so an archive past ~200 rows is truncated silently
in the UI; the entry's count is the deduplicated count of the rows returned.
Verified: pnpm typecheck/test/lint (0 errors), go build/vet, Go inbox suite
against a real Postgres, migrations up+down, and EXPLAIN confirming both new
indexes serve the query.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
158 lines
5.0 KiB
TypeScript
158 lines
5.0 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { QueryClient } from "@tanstack/react-query";
|
|
import {
|
|
onInboxInvalidate,
|
|
onInboxIssueDeleted,
|
|
onInboxIssueStatusChanged,
|
|
onInboxSummaryInvalidate,
|
|
} 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("also strips the issue from the archived list", () => {
|
|
// Deleting an issue removes its rows whether they were archived or not, so
|
|
// leaving them in the archived cache would render a row that 404s on tap.
|
|
const qc = new QueryClient();
|
|
qc.setQueryData<InboxItem[]>(inboxKeys.archived(wsId), [
|
|
makeItem("a1", "issue-a", { archived: true }),
|
|
makeItem("a2", "issue-b", { archived: true }),
|
|
]);
|
|
|
|
onInboxIssueDeleted(qc, wsId, "issue-a");
|
|
|
|
expect(
|
|
qc.getQueryData<InboxItem[]>(inboxKeys.archived(wsId))?.map((i) => i.id),
|
|
).toEqual(["a2"]);
|
|
});
|
|
|
|
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("onInboxInvalidate", () => {
|
|
it("invalidates the workspace prefix, covering both the main and archived lists", () => {
|
|
// Every inbox event can move an item across the two lists, so they are
|
|
// always refreshed together (MUL-3736).
|
|
const qc = new QueryClient();
|
|
const spy = vi.spyOn(qc, "invalidateQueries");
|
|
|
|
onInboxInvalidate(qc, wsId);
|
|
|
|
expect(spy).toHaveBeenCalledWith({ queryKey: inboxKeys.all(wsId) });
|
|
});
|
|
|
|
it("does not reach the account-level summary key", () => {
|
|
// The summary is keyed ["inbox", "unread-summary"], NOT under the
|
|
// workspace prefix — its own updater owns it.
|
|
const qc = new QueryClient();
|
|
qc.setQueryData(inboxKeys.unreadSummary(), [{ workspace_id: wsId, count: 3 }]);
|
|
const summaryQuery = qc
|
|
.getQueryCache()
|
|
.find({ queryKey: inboxKeys.unreadSummary() });
|
|
|
|
onInboxInvalidate(qc, wsId);
|
|
|
|
expect(summaryQuery?.state.isInvalidated).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("onInboxSummaryInvalidate", () => {
|
|
it("invalidates the account-level summary key regardless of active workspace", () => {
|
|
const qc = new QueryClient();
|
|
const spy = vi.spyOn(qc, "invalidateQueries");
|
|
|
|
onInboxSummaryInvalidate(qc);
|
|
|
|
expect(spy).toHaveBeenCalledWith({ queryKey: inboxKeys.unreadSummary() });
|
|
});
|
|
|
|
it("does not disturb a workspace-scoped inbox list cache", () => {
|
|
const qc = new QueryClient();
|
|
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), [makeItem("i1", "issue-a")]);
|
|
|
|
onInboxSummaryInvalidate(qc);
|
|
|
|
// The list cache entry is untouched (different key); only the summary
|
|
// query is marked stale.
|
|
expect(qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId))?.[0]?.id).toBe("i1");
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
it("patches archived rows too, which render the same status icon", () => {
|
|
const qc = new QueryClient();
|
|
qc.setQueryData<InboxItem[]>(inboxKeys.archived(wsId), [
|
|
makeItem("a1", "issue-a", { archived: true, issue_status: "todo" }),
|
|
]);
|
|
|
|
onInboxIssueStatusChanged(qc, wsId, "issue-a", "done");
|
|
|
|
expect(
|
|
qc.getQueryData<InboxItem[]>(inboxKeys.archived(wsId))?.[0]?.issue_status,
|
|
).toBe("done");
|
|
});
|
|
});
|