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>
218 lines
6.0 KiB
TypeScript
218 lines
6.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { InboxItem, InboxWorkspaceUnread } from "../types";
|
|
import {
|
|
deduplicateArchivedInboxItems,
|
|
deduplicateInboxItems,
|
|
hasOtherWorkspaceUnread,
|
|
inboxKeys,
|
|
unreadWorkspaceIds,
|
|
} from "./queries";
|
|
|
|
function item(overrides: Partial<InboxItem>): InboxItem {
|
|
return {
|
|
id: "inbox-1",
|
|
workspace_id: "workspace-1",
|
|
recipient_type: "member",
|
|
recipient_id: "member-1",
|
|
actor_type: "agent",
|
|
actor_id: "agent-1",
|
|
type: "new_comment",
|
|
severity: "info",
|
|
issue_id: "issue-1",
|
|
title: "Issue title",
|
|
body: null,
|
|
issue_status: null,
|
|
read: false,
|
|
archived: false,
|
|
created_at: "2026-06-15T08:00:00Z",
|
|
details: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("deduplicateInboxItems", () => {
|
|
it("keeps the newest issue row while preserving an older comment anchor", () => {
|
|
const merged = deduplicateInboxItems([
|
|
item({
|
|
id: "comment-notification",
|
|
type: "new_comment",
|
|
created_at: "2026-06-15T08:00:00Z",
|
|
details: { comment_id: "comment-1" },
|
|
}),
|
|
item({
|
|
id: "status-notification",
|
|
type: "status_changed",
|
|
created_at: "2026-06-15T08:01:00Z",
|
|
details: { from: "in_progress", to: "in_review" },
|
|
}),
|
|
]);
|
|
|
|
expect(merged).toHaveLength(1);
|
|
expect(merged[0]).toMatchObject({
|
|
id: "status-notification",
|
|
type: "status_changed",
|
|
details: {
|
|
from: "in_progress",
|
|
to: "in_review",
|
|
comment_id: "comment-1",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("preserves the newest row's own comment anchor", () => {
|
|
const merged = deduplicateInboxItems([
|
|
item({
|
|
id: "older-comment",
|
|
created_at: "2026-06-15T08:00:00Z",
|
|
details: { comment_id: "comment-1" },
|
|
}),
|
|
item({
|
|
id: "newer-comment",
|
|
created_at: "2026-06-15T08:02:00Z",
|
|
details: { comment_id: "comment-2" },
|
|
}),
|
|
]);
|
|
|
|
expect(merged).toHaveLength(1);
|
|
expect(merged[0]?.id).toBe("newer-comment");
|
|
expect(merged[0]?.details?.comment_id).toBe("comment-2");
|
|
});
|
|
|
|
it("drops archived rows so an optimistic archive leaves the list at once", () => {
|
|
const merged = deduplicateInboxItems([
|
|
item({ id: "active", issue_id: "issue-1" }),
|
|
item({ id: "filed-away", issue_id: "issue-2", archived: true }),
|
|
]);
|
|
|
|
expect(merged.map((i) => i.id)).toEqual(["active"]);
|
|
});
|
|
});
|
|
|
|
describe("deduplicateArchivedInboxItems", () => {
|
|
it("keeps only archived rows, one per issue, newest first", () => {
|
|
const merged = deduplicateArchivedInboxItems([
|
|
item({
|
|
id: "archived-older",
|
|
issue_id: "issue-1",
|
|
archived: true,
|
|
created_at: "2026-06-15T08:00:00Z",
|
|
}),
|
|
item({
|
|
id: "archived-newer",
|
|
issue_id: "issue-1",
|
|
archived: true,
|
|
created_at: "2026-06-15T09:00:00Z",
|
|
}),
|
|
item({
|
|
id: "archived-other-issue",
|
|
issue_id: "issue-2",
|
|
archived: true,
|
|
created_at: "2026-06-15T07:00:00Z",
|
|
}),
|
|
item({ id: "still-active", issue_id: "issue-3" }),
|
|
]);
|
|
|
|
expect(merged.map((i) => i.id)).toEqual([
|
|
"archived-newer",
|
|
"archived-other-issue",
|
|
]);
|
|
});
|
|
|
|
it("drops a row the moment an optimistic unarchive flips it back", () => {
|
|
// What useUnarchiveInbox's onMutate does: flip `archived` on the archived
|
|
// cache. The row must leave this list without waiting for the refetch.
|
|
const restored = item({ id: "restored", archived: false });
|
|
|
|
expect(deduplicateArchivedInboxItems([restored])).toEqual([]);
|
|
});
|
|
|
|
it("groups issue-less notifications on their own id rather than merging them", () => {
|
|
const merged = deduplicateArchivedInboxItems([
|
|
item({ id: "standalone-1", issue_id: null, archived: true }),
|
|
item({ id: "standalone-2", issue_id: null, archived: true }),
|
|
]);
|
|
|
|
expect(merged).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("hasOtherWorkspaceUnread", () => {
|
|
const summary = (entries: InboxWorkspaceUnread[]) => entries;
|
|
|
|
it("is true when a workspace other than the active one has unread", () => {
|
|
expect(
|
|
hasOtherWorkspaceUnread(
|
|
summary([{ workspace_id: "ws-2", count: 3 }]),
|
|
"ws-1",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("excludes the active workspace's own unread", () => {
|
|
expect(
|
|
hasOtherWorkspaceUnread(
|
|
summary([{ workspace_id: "ws-1", count: 5 }]),
|
|
"ws-1",
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("ignores other workspaces whose count is zero", () => {
|
|
expect(
|
|
hasOtherWorkspaceUnread(
|
|
summary([{ workspace_id: "ws-2", count: 0 }]),
|
|
"ws-1",
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("is true when at least one non-active workspace has unread", () => {
|
|
expect(
|
|
hasOtherWorkspaceUnread(
|
|
summary([
|
|
{ workspace_id: "ws-1", count: 4 },
|
|
{ workspace_id: "ws-2", count: 1 },
|
|
]),
|
|
"ws-1",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("is false for an empty summary", () => {
|
|
expect(hasOtherWorkspaceUnread([], "ws-1")).toBe(false);
|
|
});
|
|
|
|
it("counts every workspace as 'other' when there is no active workspace", () => {
|
|
expect(
|
|
hasOtherWorkspaceUnread(
|
|
summary([{ workspace_id: "ws-1", count: 2 }]),
|
|
null,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("unreadWorkspaceIds", () => {
|
|
it("collects only workspaces with a non-zero count", () => {
|
|
const ids = unreadWorkspaceIds([
|
|
{ workspace_id: "ws-1", count: 0 },
|
|
{ workspace_id: "ws-2", count: 3 },
|
|
{ workspace_id: "ws-3", count: 1 },
|
|
]);
|
|
expect(ids.has("ws-1")).toBe(false);
|
|
expect(ids.has("ws-2")).toBe(true);
|
|
expect(ids.has("ws-3")).toBe(true);
|
|
expect(ids.size).toBe(2);
|
|
});
|
|
|
|
it("returns an empty set for an empty summary", () => {
|
|
expect(unreadWorkspaceIds([]).size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("inboxKeys.unreadSummary", () => {
|
|
it("is a stable account-level key independent of any workspace", () => {
|
|
expect(inboxKeys.unreadSummary()).toEqual(["inbox", "unread-summary"]);
|
|
});
|
|
});
|