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>
73 lines
2.8 KiB
TypeScript
73 lines
2.8 KiB
TypeScript
import type { QueryClient } from "@tanstack/react-query";
|
|
import { inboxKeys } from "./queries";
|
|
import type { InboxItem, IssueStatus } from "../types";
|
|
|
|
export function onInboxNew(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
_item: InboxItem,
|
|
) {
|
|
// Use invalidateQueries instead of setQueryData — triggers a refetch that
|
|
// reliably notifies all observers. The inbox list is small so this is cheap.
|
|
//
|
|
// Both lists: a new notification on an ARCHIVED issue puts that issue back in
|
|
// the main inbox, which means it must also leave the archived list. The
|
|
// server owns that split (ListArchivedInboxItems excludes issues with an
|
|
// active row), so refetching both is what keeps them mutually exclusive.
|
|
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
|
|
}
|
|
|
|
export function patchInboxIssueStatus(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
status: IssueStatus,
|
|
) {
|
|
const patch = (old: InboxItem[] | undefined) =>
|
|
old?.map((i) => (i.issue_id === issueId ? { ...i, issue_status: status } : i));
|
|
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), patch);
|
|
// Archived rows render the same status icon, so they need the same patch.
|
|
qc.setQueryData<InboxItem[]>(inboxKeys.archived(wsId), patch);
|
|
}
|
|
|
|
export function onInboxIssueStatusChanged(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
status: IssueStatus,
|
|
) {
|
|
patchInboxIssueStatus(qc, wsId, issueId, status);
|
|
}
|
|
|
|
// 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 — from the archived list as well, which holds rows
|
|
// for the same issues.
|
|
export function onInboxIssueDeleted(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
) {
|
|
const drop = (old: InboxItem[] | undefined) =>
|
|
old?.filter((i) => i.issue_id !== issueId);
|
|
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), drop);
|
|
qc.setQueryData<InboxItem[]>(inboxKeys.archived(wsId), drop);
|
|
}
|
|
|
|
// Refresh both the main and archived lists. Every inbox event can move an item
|
|
// across that boundary (archive, unarchive, or a new notification reviving an
|
|
// archived issue), and the split is decided server-side, so the two are always
|
|
// invalidated together.
|
|
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
|
|
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
|
|
}
|
|
|
|
// Refresh the cross-workspace unread summary (workspace-switcher dot). The
|
|
// summary spans every workspace, so it is invalidated on ANY inbox event
|
|
// regardless of which workspace the event came from — including read/archive
|
|
// events from a workspace other than the active one, which the workspace-
|
|
// scoped list invalidation cannot reach.
|
|
export function onInboxSummaryInvalidate(qc: QueryClient) {
|
|
qc.invalidateQueries({ queryKey: inboxKeys.unreadSummary() });
|
|
}
|