Compare commits

...

3 Commits

Author SHA1 Message Date
Lambda
117c7ba6ae fix(inbox): keep scope/availability caches fresh on issue reassign + relation changes
- issue:updated WS + useUpdateIssue / useBatchUpdateIssues now invalidate
  inboxKeys.list + scopeCounts so assignee_scope-derived chip filtering,
  badges, and bulk operations don't lag the actual scope.
- onInboxInvalidate / onInboxIssueDeleted also flush scopeCounts so
  single-row archived/read events and CASCADE-deletes refresh the chip
  badge alongside the list.
- agent / member / squad refresh handlers invalidate
  inboxKeys.resourceAvailability so chip enabled state reacts to the
  first owned-agent / squad-membership / squad creation event instead
  of waiting for reload.
- Inbox page header unread count derives from filtered items rather
  than the global useInboxUnreadCount so the badge matches the visible
  list; sidebar / desktop badge stay on the global count.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 23:18:36 +08:00
Lambda
7ac797fcd8 refactor(inbox): rename batch-archived operation literal archive_all_read → archive_read
RFC v4 final naming (per Xeon directive on MUL-2426): the three places that
must agree on the operation literal are the server `inbox:batch-archived`
event payload, the bulk-endpoint handler switch, and the frontend
`InboxBatchArchiveOperation` discriminated union. UI menu / error i18n keys
are unrelated and stay as-is.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 17:00:37 +08:00
Lambda
fd913a2596 feat(inbox): add assignment filter chips (assigned to me / my agent / my squad) (MUL-2426)
Implements RFC v3 + the two v4 deltas (operation field on inbox:batch-archived,
scoped.id/issue_id alias). Server-side first (SQL + handler + WS payload +
resource-availability), then frontend (chip UI + store + dynamic bulk labels).

Backend:
- Migration 095: SQL function squad_involves_user mirroring the
  ListIssues involves_user_id semantics so the inbox scope predicate
  can't drift from My Issues.
- ListInboxItems now tags each row with assignee_scope (me / my_agent /
  my_squad / other / none) and accepts an optional scopes filter.
- New endpoints: GET /api/inbox/scope-counts (post-dedup), GET
  /api/inbox/resource-availability (decoupled chip-disabled signal).
- mark-all-read + 3 archive endpoints accept ?scope=...; archive-* emit
  inbox:batch-archived with operation + scope so listeners can pick
  the right predicate when applying precise cache updates.

Frontend:
- New workspace-aware inbox-scope-store; default = all 3 chips selected.
- resolveInboxFilter implements the all / subset / empty algorithm.
- InboxFilterChips component with disabled-but-selected state machine
  (S1-S4) and tooltips, sourced from resource-availability rather than
  scope counts.
- Bulk actions disabled in empty mode, label swaps to "filtered" copy
  in subset mode.
- WS handlers for inbox:batch-read / inbox:batch-archived wired in to
  refresh other devices.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:55:33 +08:00
23 changed files with 1317 additions and 209 deletions

View File

@@ -23,6 +23,9 @@ import type {
AgentRunCount,
AgentRuntime,
InboxItem,
InboxFilterScope,
InboxScopeCounts,
InboxResourceAvailability,
IssueSubscriber,
Comment,
Reaction,
@@ -186,6 +189,14 @@ const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE:
issue_id: "",
};
// Serialize the inbox `scope` filter into a `?scope=me,my_agent` query
// fragment. The server rejects empty arrays, so callers must skip the bulk
// request entirely when no chip is selected (RFC v3 §E.1, mode=empty).
function inboxScopeQuery(scope?: InboxFilterScope[] | null): string {
if (!scope || scope.length === 0) return "";
return `?scope=${encodeURIComponent(scope.join(","))}`;
}
// --- Starter content (post-onboarding import) -----------------------------
// Shape mirrors the Go request/response in handler/onboarding.go.
//
@@ -1095,8 +1106,8 @@ export class ApiClient {
}
// Inbox
async listInbox(): Promise<InboxItem[]> {
return this.fetch("/api/inbox");
async listInbox(scope?: InboxFilterScope[]): Promise<InboxItem[]> {
return this.fetch(`/api/inbox${inboxScopeQuery(scope)}`);
}
async markInboxRead(id: string): Promise<InboxItem> {
@@ -1111,20 +1122,28 @@ export class ApiClient {
return this.fetch("/api/inbox/unread-count");
}
async markAllInboxRead(): Promise<{ count: number }> {
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
async getInboxScopeCounts(): Promise<InboxScopeCounts> {
return this.fetch("/api/inbox/scope-counts");
}
async archiveAllInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-all", { method: "POST" });
async getInboxResourceAvailability(): Promise<InboxResourceAvailability> {
return this.fetch("/api/inbox/resource-availability");
}
async archiveAllReadInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-all-read", { method: "POST" });
async markAllInboxRead(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/mark-all-read${inboxScopeQuery(scope)}`, { method: "POST" });
}
async archiveCompletedInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
async archiveAllInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/archive-all${inboxScopeQuery(scope)}`, { method: "POST" });
}
async archiveAllReadInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/archive-all-read${inboxScopeQuery(scope)}`, { method: "POST" });
}
async archiveCompletedInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/archive-completed${inboxScopeQuery(scope)}`, { method: "POST" });
}
// Notification preferences

View File

@@ -1,3 +1,4 @@
export * from "./queries";
export * from "./mutations";
export * from "./ws-updaters";
export * from "./stores";

View File

@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { inboxKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { InboxItem } from "../types";
import type { InboxItem, InboxFilterScope } from "../types";
export function useMarkInboxRead() {
const qc = useQueryClient();
@@ -22,6 +22,7 @@ export function useMarkInboxRead() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -51,21 +52,27 @@ export function useArchiveInbox() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
// All bulk mutations accept an optional `scope` parameter. When the caller
// is in mode=all (RFC v3 §E.1) it should pass undefined; when in mode=subset
// it should pass the resolved chip subset; in mode=empty the button is
// disabled and these mutations should not fire.
export function useMarkAllInboxRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.markAllInboxRead(),
onMutate: async () => {
mutationFn: (scope?: InboxFilterScope[]) => api.markAllInboxRead(scope),
onMutate: async (scope) => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
const inScope = scopeMatcher(scope);
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) =>
!item.archived ? { ...item, read: true } : item,
!item.archived && inScope(item) ? { ...item, read: true } : item,
),
);
return { prev };
@@ -75,6 +82,7 @@ export function useMarkAllInboxRead() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -83,9 +91,10 @@ export function useArchiveAllInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllInbox(),
mutationFn: (scope?: InboxFilterScope[]) => api.archiveAllInbox(scope),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -94,9 +103,10 @@ export function useArchiveAllReadInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllReadInbox(),
mutationFn: (scope?: InboxFilterScope[]) => api.archiveAllReadInbox(scope),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -105,9 +115,21 @@ export function useArchiveCompletedInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveCompletedInbox(),
mutationFn: (scope?: InboxFilterScope[]) => api.archiveCompletedInbox(scope),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
// True when the inbox item belongs to the user-selected scope subset, or
// when no scope was passed (= mark/archive everything).
function scopeMatcher(scope?: InboxFilterScope[]) {
if (!scope || scope.length === 0) return (_item: InboxItem) => true;
const set = new Set(scope);
return (item: InboxItem) => {
const s = item.assignee_scope;
return s != null && (set as Set<string>).has(s);
};
}

View File

@@ -1,19 +1,49 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { api } from "../api";
import type { InboxItem } from "../types";
import type {
InboxItem,
InboxFilterScope,
InboxScopeCounts,
InboxResourceAvailability,
} from "../types";
export const inboxKeys = {
all: (wsId: string) => ["inbox", wsId] as const,
// The list key is intentionally a single key per workspace — the scope
// filter is applied client-side on top of the full cached list (RFC v3
// §E selector), so we don't fragment the cache by scope. When the user
// changes chips we just re-derive from the same query.
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
scopeCounts: (wsId: string) =>
[...inboxKeys.all(wsId), "scope-counts"] as const,
resourceAvailability: (wsId: string) =>
[...inboxKeys.all(wsId), "resource-availability"] as const,
};
export function inboxListOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.list(wsId),
// Always fetch the full list (no scope param). The chip filter runs in
// the selector — that way the badge counts and the dedupe logic always
// operate on the complete picture, and toggling a chip is instant.
queryFn: () => api.listInbox(),
});
}
export function inboxScopeCountsOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.scopeCounts(wsId),
queryFn: () => api.getInboxScopeCounts(),
});
}
export function inboxResourceAvailabilityOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.resourceAvailability(wsId),
queryFn: () => api.getInboxResourceAvailability(),
});
}
/**
* Unread inbox count for the given workspace, aligned with what the inbox
* list UI renders: archived items excluded, then deduplicated by issue so a
@@ -57,3 +87,29 @@ export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
}
/**
* Narrow a deduplicated inbox list to the user-selected chips. Applies the
* RFC v3 §E selector rules: a strict subset of {me, my_agent, my_squad}
* keeps only items tagged with one of those scopes (other/none are dropped);
* a null filter (= "all" mode) passes everything through unchanged.
*
* `null` is the no-op signal. Pass `null` whenever you don't want to filter,
* including the empty-mode case where the caller is also expected to render
* an empty state instead of calling this.
*/
export function filterInboxByScope(
items: InboxItem[],
scopes: InboxFilterScope[] | null,
): InboxItem[] {
if (!scopes) return items;
const set = new Set(scopes);
return items.filter((i) => {
const s = i.assignee_scope;
return s != null && (set as Set<string>).has(s);
});
}
// Re-exports — kept for backwards compatibility with code importing the
// inbox scope-count / availability response shapes from this module.
export type { InboxScopeCounts, InboxResourceAvailability };

View File

@@ -0,0 +1,83 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
import type { InboxFilterScope } from "../../types";
// All three assignment chips, in stable display order. Used both for the
// "default = all selected" initial state and for callers that need to render
// chips deterministically.
export const INBOX_FILTER_SCOPES: readonly InboxFilterScope[] = [
"me",
"my_agent",
"my_squad",
] as const;
interface InboxScopeState {
// Persisted selection. The default is the full set so a freshly installed
// app shows every notification — see RFC v3 §E.1 mode=all.
selected: InboxFilterScope[];
toggle: (scope: InboxFilterScope) => void;
set: (scopes: InboxFilterScope[]) => void;
selectAll: () => void;
clear: () => void;
}
export const useInboxScopeStore = create<InboxScopeState>()(
persist(
(set) => ({
selected: [...INBOX_FILTER_SCOPES],
toggle: (scope) =>
set((state) => ({
selected: state.selected.includes(scope)
? state.selected.filter((s) => s !== scope)
: [...state.selected, scope],
})),
set: (scopes) => set({ selected: scopes }),
selectAll: () => set({ selected: [...INBOX_FILTER_SCOPES] }),
clear: () => set({ selected: [] }),
}),
{
name: "multica_inbox_scope",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useInboxScopeStore.persist.rehydrate());
// Resolved filter mode. Matches the three-state algorithm in RFC v3 §E.1:
// - all: 3 selected → no `scope` is sent; selector keeps me/my_agent/my_squad/other/none
// - subset: 1-2 selected → `scope=...` is sent; selector filters to the subset
// - empty: 0 selected → don't request; show empty state, bulk disabled
export type InboxFilterMode = "all" | "subset" | "empty";
export interface InboxFilterResolution {
mode: InboxFilterMode;
// Scopes to send on the wire. `null` for mode="all" (omit param entirely),
// a string[] for mode="subset", `[]` for mode="empty".
scopes: InboxFilterScope[] | null;
}
export function resolveInboxFilter(
selected: InboxFilterScope[],
): InboxFilterResolution {
// Dedupe + restrict to the three valid chip values. "other" / "none" are
// server-internal buckets and must never appear on the wire.
const unique = new Set<InboxFilterScope>();
for (const s of selected) {
if (s === "me" || s === "my_agent" || s === "my_squad") unique.add(s);
}
if (unique.size === INBOX_FILTER_SCOPES.length) {
return { mode: "all", scopes: null };
}
if (unique.size === 0) {
return { mode: "empty", scopes: [] };
}
return {
mode: "subset",
scopes: INBOX_FILTER_SCOPES.filter((s) => unique.has(s)),
};
}

View File

@@ -0,0 +1,7 @@
export {
useInboxScopeStore,
resolveInboxFilter,
INBOX_FILTER_SCOPES,
type InboxFilterMode,
type InboxFilterResolution,
} from "./inbox-scope-store";

View File

@@ -10,6 +10,19 @@ export function onInboxNew(
// Use invalidateQueries instead of setQueryData — triggers a refetch that
// reliably notifies all observers. The inbox list is small so this is cheap.
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
// `inbox:batch-read` and `inbox:batch-archived` are emitted when the user
// runs a bulk endpoint (mark-all-read / archive-*). They can carry a `scope`
// filter (RFC v3 §C.5) and `inbox:batch-archived` additionally carries an
// `operation` (RFC v4 §1). We currently fall back to a generic invalidate
// for both — precise cache updates per operation+scope are a documented
// follow-up: the payload contract is already in place, so the optimization
// is a frontend-only change later.
export function onInboxBatch(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
export function onInboxIssueStatusChanged(
@@ -27,7 +40,9 @@ 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.
// them from the cache too. Scope counts shift in lockstep with the pruned
// rows, so invalidate them here as well — otherwise the chip badge keeps
// counting an issue that no longer exists.
export function onInboxIssueDeleted(
qc: QueryClient,
wsId: string,
@@ -36,8 +51,14 @@ export function onInboxIssueDeleted(
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.filter((i) => i.issue_id !== issueId),
);
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
// Generic single-item inbox invalidation (e.g. `inbox:archived`,
// `inbox:read`). The chip badge is derived from the same rows that just
// changed, so it has to be re-fetched alongside the list — otherwise the
// badge stays at the pre-change value until a hard refresh.
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}

View File

@@ -25,6 +25,7 @@ import {
pruneDeletedIssueFromParentChildrenCaches,
} from "./delete-cache";
import { useWorkspaceId } from "../hooks";
import { inboxKeys } from "../inbox/queries";
import { useRecentIssuesStore } from "./stores";
import type { GroupedIssuesResponse, Issue, IssueAssigneeGroup, IssueReaction, IssueStatus } from "../types";
import type {
@@ -327,6 +328,20 @@ export function useUpdateIssue() {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
// Inbox rows carry a server-computed `assignee_scope` derived from
// the issue's assignee. Re-assigning the issue (member ↔ agent ↔
// squad ↔ none) shifts the row's chip bucket and the scope-count
// badge, so flush both whenever this mutation touched assignment.
// The WS handler also invalidates on the broadcast issue:updated;
// doing it here too lets the originating tab refresh without
// round-tripping through the server.
if (
Object.prototype.hasOwnProperty.call(vars, "assignee_id") ||
Object.prototype.hasOwnProperty.call(vars, "assignee_type")
) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
// Refresh the issue's attachments cache when the description editor
// bound new uploads — the description editor reads `issueAttachments`
// to resolve text-preview Eye gates, and unlike other mutations this
@@ -465,10 +480,19 @@ export function useBatchUpdateIssues() {
}
}
},
onSettled: (_data, _err, _vars, ctx) => {
onSettled: (_data, _err, vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
// Bulk reassignments shift `assignee_scope` across N rows — same
// reasoning as useUpdateIssue.
if (
Object.prototype.hasOwnProperty.call(vars.updates, "assignee_id") ||
Object.prototype.hasOwnProperty.call(vars.updates, "assignee_type")
) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
for (const parentId of ctx.affectedParentIds) {
qc.invalidateQueries({

View File

@@ -37,6 +37,8 @@
"./inbox/queries": "./inbox/queries.ts",
"./inbox/mutations": "./inbox/mutations.ts",
"./inbox/ws-updaters": "./inbox/ws-updaters.ts",
"./inbox/stores": "./inbox/stores/index.ts",
"./inbox/stores/*": "./inbox/stores/*.ts",
"./notification-preferences": "./notification-preferences/index.ts",
"./notification-preferences/queries": "./notification-preferences/queries.ts",
"./notification-preferences/mutations": "./notification-preferences/mutations.ts",

View File

@@ -27,7 +27,7 @@ import {
onIssueDeleted,
onIssueLabelsChanged,
} from "../issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted, onInboxBatch } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
import { notificationPreferenceOptions } from "../notification-preferences/queries";
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
@@ -217,11 +217,22 @@ export function useRealtimeSync(
// the per-squad members-status cache. Prefix-matches both the
// squad list and every squadMemberStatus query.
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
// Creating/deleting the user's first owned agent flips
// `has_my_agent`, which gates the "my agent" chip's
// disabled-but-selected state. Refresh the resource-availability
// probe so the chip un-greys (or greys) on the first relationship
// change instead of waiting for reload.
qc.invalidateQueries({ queryKey: inboxKeys.resourceAvailability(wsId) });
}
},
member: () => {
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
if (wsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
// Member adds/removes can flip `has_my_squad` (user joining or
// leaving a squad as a human member). Mirror the agent handler.
qc.invalidateQueries({ queryKey: inboxKeys.resourceAvailability(wsId) });
}
},
// workspace:updated is handled by the specific handler below
// (compares prefixes to decide whether to also invalidate issues).
@@ -245,6 +256,10 @@ export function useRealtimeSync(
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
// squad:deleted triggers assignee transfer — refresh issues too.
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
// Creating/deleting a squad the user is involved in flips
// `has_my_squad`. Refresh resource-availability so the
// "my squad" chip's disabled state reacts in realtime.
qc.invalidateQueries({ queryKey: inboxKeys.resourceAvailability(wsId) });
}
},
label: () => {
@@ -342,6 +357,7 @@ export function useRealtimeSync(
const specificEvents = new Set([
"workspace:updated",
"issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "inbox:new",
"inbox:batch-read", "inbox:batch-archived",
"comment:created", "comment:updated", "comment:deleted",
"comment:resolved", "comment:unresolved",
"activity:created",
@@ -385,6 +401,13 @@ export function useRealtimeSync(
if (issue.status) {
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);
}
// The inbox row's `assignee_scope` is derived from the issue's
// assignee, so any issue:updated event may have shifted it (the
// payload doesn't tell us which fields changed). Invalidate the
// inbox list and scope counts so chip filtering, chip badges, and
// scope-targeted bulk actions reflect the new scope without
// requiring a full reload.
onInboxInvalidate(qc, wsId);
}
});
@@ -470,6 +493,21 @@ export function useRealtimeSync(
});
});
// Bulk mark-all-read / archive-* on another device — refresh this device's
// inbox so the change appears. The payload carries `scope` (and for
// archived, `operation`) per RFC v3 §C.5 / v4 §1; precise cache updates
// off those fields are a documented follow-up — invalidate is the safe
// baseline today.
const unsubInboxBatchRead = ws.on("inbox:batch-read", () => {
const wsId = getCurrentWsId();
if (wsId) onInboxBatch(qc, wsId);
});
const unsubInboxBatchArchived = ws.on("inbox:batch-archived", () => {
const wsId = getCurrentWsId();
if (wsId) onInboxBatch(qc, wsId);
});
// --- Timeline event handlers (global fallback) ---
// These events are also handled granularly by useIssueTimeline when
// IssueDetail is mounted. This global handler exists to mark the
@@ -879,6 +917,8 @@ export function useRealtimeSync(
unsubIssueDeleted();
unsubIssueLabelsChanged();
unsubInboxNew();
unsubInboxBatchRead();
unsubInboxBatchArchived();
unsubCommentCreated();
unsubCommentUpdated();
unsubCommentDeleted();

View File

@@ -135,11 +135,20 @@ export interface InboxArchivedPayload {
export interface InboxBatchReadPayload {
recipient_id: string;
count: number;
// Optional assignment-scope filter the originating mark-all-read was
// narrowed to (RFC v3 §C.5). When present, listeners may apply a precise
// cache update; when absent, the safe default is a full inbox invalidate.
scope?: import("./inbox").InboxFilterScope[] | null;
}
export interface InboxBatchArchivedPayload {
recipient_id: string;
count: number;
// Identifies the bulk archive variant so listeners can pick the right
// predicate for a precise cache update (RFC v4 §1). Optional for backward
// compatibility with older servers.
operation?: import("./inbox").InboxBatchArchiveOperation | null;
scope?: import("./inbox").InboxFilterScope[] | null;
}
export interface CommentCreatedPayload {

View File

@@ -21,6 +21,22 @@ export type InboxItemType =
| "quick_create_done"
| "quick_create_failed";
/**
* Inbox assignment scope buckets (RFC v3 §B). The three "my_*" values map to
* the user-selectable chips; "other" and "none" are server-internal fallback
* buckets that fill the default-no-filter view but cannot be explicitly
* filtered to.
*/
export type InboxAssigneeScope =
| "me"
| "my_agent"
| "my_squad"
| "other"
| "none";
/** User-selectable subset of InboxAssigneeScope (chips). */
export type InboxFilterScope = "me" | "my_agent" | "my_squad";
export interface InboxItem {
id: string;
workspace_id: string;
@@ -38,4 +54,26 @@ export interface InboxItem {
archived: boolean;
created_at: string;
details: Record<string, string> | null;
// Server-tagged scope of the issue this inbox item references (RFC v3 §A).
// Optional because older servers may not emit it.
issue_assignee_type?: "member" | "agent" | "squad" | null;
issue_assignee_id?: string | null;
assignee_scope?: InboxAssigneeScope | null;
}
export type InboxScopeCounts = Record<InboxAssigneeScope, number>;
export interface InboxResourceAvailability {
has_my_agent: boolean;
has_my_squad: boolean;
}
/**
* Identifies which bulk-archive endpoint produced an `inbox:batch-archived`
* WS event. Frontends use this to choose the right predicate when applying a
* precise cache update (RFC v4 §1).
*/
export type InboxBatchArchiveOperation =
| "archive_all"
| "archive_read"
| "archive_completed";

View File

@@ -49,7 +49,16 @@ export type {
IssueUsageSummary,
} from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type {
InboxItem,
InboxSeverity,
InboxItemType,
InboxAssigneeScope,
InboxFilterScope,
InboxScopeCounts,
InboxResourceAvailability,
InboxBatchArchiveOperation,
} from "./inbox";
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";

View File

@@ -0,0 +1,113 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import {
inboxScopeCountsOptions,
inboxResourceAvailabilityOptions,
} from "@multica/core/inbox/queries";
import {
useInboxScopeStore,
INBOX_FILTER_SCOPES,
} from "@multica/core/inbox/stores";
import type { InboxFilterScope } from "@multica/core/types";
import { cn } from "@multica/ui/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { useT } from "../../i18n";
const SCOPE_KEYS: readonly InboxFilterScope[] = INBOX_FILTER_SCOPES;
// Inbox assignment-filter chip row (RFC v3 §B/§D). Renders three chips with
// the disabled-but-selected state machine: a chip with the underlying
// resource (agent / squad) missing is dimmed, and the user's saved selection
// is preserved silently so the chip springs back to life when the resource
// returns.
export function InboxFilterChips() {
const { t } = useT("inbox");
const wsId = useWorkspaceId();
const { data: counts } = useQuery(inboxScopeCountsOptions(wsId));
const { data: resources } = useQuery(inboxResourceAvailabilityOptions(wsId));
const selected = useInboxScopeStore((s) => s.selected);
const toggle = useInboxScopeStore((s) => s.toggle);
// Resource availability defaults to "true" while loading so the chips stay
// interactive on first paint — a one-frame flash to disabled would feel
// worse than the rare edge of letting a user click a chip that briefly
// ends up empty.
const hasMyAgent = resources?.has_my_agent ?? true;
const hasMySquad = resources?.has_my_squad ?? true;
return (
<TooltipProvider>
<div
className="flex items-center gap-1.5 border-b px-3 py-2"
role="group"
aria-label={t(($) => $.filter.aria_label)}
>
{SCOPE_KEYS.map((scope) => {
const isSelected = selected.includes(scope);
const disabled =
(scope === "my_agent" && !hasMyAgent) ||
(scope === "my_squad" && !hasMySquad);
const count = counts?.[scope] ?? 0;
const label =
scope === "me"
? t(($) => $.filter.scopes.me)
: scope === "my_agent"
? t(($) => $.filter.scopes.my_agent)
: t(($) => $.filter.scopes.my_squad);
const tooltipText = disabled
? isSelected
? scope === "my_agent"
? t(($) => $.filter.tooltip.no_agent_selected)
: t(($) => $.filter.tooltip.no_squad_selected)
: scope === "my_agent"
? t(($) => $.filter.tooltip.no_agent)
: t(($) => $.filter.tooltip.no_squad)
: null;
const button = (
<button
type="button"
aria-pressed={isSelected}
aria-disabled={disabled}
disabled={disabled && !isSelected}
onClick={() => {
// Disabled-but-selected chips remain interactive: clicking
// them should be able to deselect, otherwise the user has no
// way to drop a stale preference besides editing storage.
if (disabled && !isSelected) return;
toggle(scope);
}}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs transition-colors",
isSelected
? "border-primary/40 bg-primary/10 text-primary"
: "border-border bg-muted/40 text-muted-foreground hover:text-foreground",
disabled && (isSelected ? "opacity-60" : "opacity-45 cursor-not-allowed"),
)}
>
<span>{label}</span>
{count > 0 && (
<span className="text-[10px] text-muted-foreground">{count}</span>
)}
</button>
);
if (!tooltipText) return <span key={scope}>{button}</span>;
return (
<Tooltip key={scope}>
<TooltipTrigger render={button} />
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
);
}

View File

@@ -10,7 +10,7 @@ import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import {
inboxListOptions,
deduplicateInboxItems,
useInboxUnreadCount,
filterInboxByScope,
} from "@multica/core/inbox/queries";
import {
useMarkInboxRead,
@@ -20,6 +20,10 @@ import {
useArchiveAllReadInbox,
useArchiveCompletedInbox,
} from "@multica/core/inbox/mutations";
import {
useInboxScopeStore,
resolveInboxFilter,
} from "@multica/core/inbox/stores";
import { IssueDetail } from "../../issues/components";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
@@ -54,6 +58,7 @@ import { PageHeader } from "../../layout/page-header";
import { InboxListItem, useTimeAgo } from "./inbox-list-item";
import { useTypeLabels } from "./inbox-detail-label";
import { getInboxDisplayTitle } from "./inbox-display";
import { InboxFilterChips } from "./inbox-filter-chips";
import { useT } from "../../i18n";
export function InboxPage() {
@@ -71,7 +76,16 @@ export function InboxPage() {
const wsId = useWorkspaceId();
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
const selectedScopes = useInboxScopeStore((s) => s.selected);
const selectAllScopes = useInboxScopeStore((s) => s.selectAll);
// RFC v3 §E.1: filter mode resolves to one of all/subset/empty. Bulk
// actions, the list selector, and the empty-state UI are all driven from
// this single derived value.
const filter = useMemo(() => resolveInboxFilter(selectedScopes), [selectedScopes]);
const items = useMemo(
() => filterInboxByScope(deduplicateInboxItems(rawItems), filter.scopes),
[rawItems, filter.scopes],
);
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
@@ -113,7 +127,15 @@ export function InboxPage() {
});
const isMobile = useIsMobile();
const unreadCount = useInboxUnreadCount(wsId);
// Header unread count tracks the *currently visible* list, not the
// workspace-wide total. Subset filtering and the empty-mode (0 chips)
// case would otherwise show a number that doesn't match the rows below.
// Sidebar / desktop badge consumers keep using `useInboxUnreadCount` for
// the global figure.
const unreadCount = useMemo(
() => items.filter((i) => !i.read).length,
[items],
);
const markReadMutation = useMarkInboxRead();
const archiveMutation = useArchiveInbox();
@@ -171,9 +193,16 @@ export function InboxPage() {
});
};
// Batch operations
// Batch operations. Each routes the resolved scope through to the mutation
// so the server narrows the bulk query (RFC v3 §C). mode=empty short-circuits
// before sending — the user is meant to see the empty state, not silently
// get a 400 from the backend.
const bulkScope = filter.mode === "subset" ? filter.scopes ?? undefined : undefined;
const bulkDisabled = filter.mode === "empty";
const handleMarkAllRead = () => {
markAllReadMutation.mutate(undefined, {
if (bulkDisabled) return;
markAllReadMutation.mutate(bulkScope, {
onError: (err) =>
toast.error(
err instanceof Error && err.message
@@ -184,8 +213,9 @@ export function InboxPage() {
};
const handleArchiveAll = () => {
if (bulkDisabled) return;
setSelectedKey("");
archiveAllMutation.mutate(undefined, {
archiveAllMutation.mutate(bulkScope, {
onError: (err) =>
toast.error(
err instanceof Error && err.message
@@ -196,9 +226,10 @@ export function InboxPage() {
};
const handleArchiveAllRead = () => {
if (bulkDisabled) return;
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
if (readKeys.includes(selectedKey)) setSelectedKey("");
archiveAllReadMutation.mutate(undefined, {
archiveAllReadMutation.mutate(bulkScope, {
onError: (err) =>
toast.error(
err instanceof Error && err.message
@@ -209,8 +240,9 @@ export function InboxPage() {
};
const handleArchiveCompleted = () => {
if (bulkDisabled) return;
setSelectedKey("");
archiveCompletedMutation.mutate(undefined, {
archiveCompletedMutation.mutate(bulkScope, {
onError: (err) =>
toast.error(
err instanceof Error && err.message
@@ -222,55 +254,93 @@ export function InboxPage() {
// -- Shared sub-components --------------------------------------------------
// Bulk-action labels swap to a "current filter" variant when the user has
// narrowed the inbox to a subset. The dropdown is disabled entirely in
// mode=empty (RFC v3 §C / §E.1) so users don't fire a bulk action against
// an empty visible list.
const isSubset = filter.mode === "subset";
const markAllReadLabel = isSubset
? t(($) => $.menu.mark_all_read_scoped)
: t(($) => $.menu.mark_all_read);
const archiveAllLabel = isSubset
? t(($) => $.menu.archive_all_scoped)
: t(($) => $.menu.archive_all);
const archiveAllReadLabel = isSubset
? t(($) => $.menu.archive_all_read_scoped)
: t(($) => $.menu.archive_all_read);
const archiveCompletedLabel = isSubset
? t(($) => $.menu.archive_completed_scoped)
: t(($) => $.menu.archive_completed);
const listHeader = (
<PageHeader className="justify-between">
<div className="flex items-center gap-2">
<h1 className="text-sm font-semibold">{t(($) => $.page.title)}</h1>
{unreadCount > 0 && (
<span className="text-xs text-muted-foreground">
{unreadCount}
</span>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
/>
}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuItem onClick={handleMarkAllRead}>
<CheckCheck className="h-4 w-4" />
{t(($) => $.menu.mark_all_read)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleArchiveAll}>
<Archive className="h-4 w-4" />
{t(($) => $.menu.archive_all)}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleArchiveAllRead}>
<BookCheck className="h-4 w-4" />
{t(($) => $.menu.archive_all_read)}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleArchiveCompleted}>
<ListChecks className="h-4 w-4" />
{t(($) => $.menu.archive_completed)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</PageHeader>
<>
<PageHeader className="justify-between">
<div className="flex items-center gap-2">
<h1 className="text-sm font-semibold">{t(($) => $.page.title)}</h1>
{unreadCount > 0 && (
<span className="text-xs text-muted-foreground">
{unreadCount}
</span>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
disabled={bulkDisabled}
/>
}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuItem onClick={handleMarkAllRead}>
<CheckCheck className="h-4 w-4" />
{markAllReadLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleArchiveAll}>
<Archive className="h-4 w-4" />
{archiveAllLabel}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleArchiveAllRead}>
<BookCheck className="h-4 w-4" />
{archiveAllReadLabel}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleArchiveCompleted}>
<ListChecks className="h-4 w-4" />
{archiveCompletedLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</PageHeader>
<InboxFilterChips />
</>
);
const listBody = items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm">{t(($) => $.list.empty)}</p>
<p className="text-sm">
{filter.mode === "empty"
? t(($) => $.list.empty_no_filter)
: filter.mode === "subset"
? t(($) => $.list.empty_filtered)
: t(($) => $.list.empty)}
</p>
{filter.mode !== "all" && (
<Button
variant="ghost"
size="sm"
className="mt-2 text-xs"
onClick={() => selectAllScopes()}
>
{t(($) => $.list.show_all)}
</Button>
)}
</div>
) : (
<div>

View File

@@ -5,12 +5,33 @@
},
"menu": {
"mark_all_read": "Mark all as read",
"mark_all_read_scoped": "Mark filtered as read",
"archive_all": "Archive all",
"archive_all_scoped": "Archive filtered",
"archive_all_read": "Archive all read",
"archive_completed": "Archive completed"
"archive_all_read_scoped": "Archive filtered read",
"archive_completed": "Archive completed",
"archive_completed_scoped": "Archive filtered completed"
},
"filter": {
"aria_label": "Filter inbox by assignment",
"scopes": {
"me": "Assigned to me",
"my_agent": "My agent",
"my_squad": "My squad"
},
"tooltip": {
"no_agent": "You don't own any agent yet.",
"no_squad": "You're not part of any squad yet.",
"no_agent_selected": "You don't own any agent right now, but your filter is saved.",
"no_squad_selected": "You're not part of any squad right now, but your filter is saved."
}
},
"list": {
"empty": "No notifications",
"empty_filtered": "No notifications match the current filter",
"empty_no_filter": "No filter selected",
"show_all": "Show all",
"mark_done_tooltip": "Mark as done",
"archive_tooltip": "Archive",
"time": {

View File

@@ -5,12 +5,33 @@
},
"menu": {
"mark_all_read": "全部标为已读",
"mark_all_read_scoped": "当前筛选标为已读",
"archive_all": "归档全部",
"archive_all_scoped": "归档当前筛选",
"archive_all_read": "归档全部已读",
"archive_completed": "归档已完成"
"archive_all_read_scoped": "归档当前筛选已读",
"archive_completed": "归档已完成",
"archive_completed_scoped": "归档当前筛选已完成"
},
"filter": {
"aria_label": "按分配筛选收件箱",
"scopes": {
"me": "分给我",
"my_agent": "我的智能体",
"my_squad": "我的小组"
},
"tooltip": {
"no_agent": "你还没有任何智能体。",
"no_squad": "你还没有加入任何小组。",
"no_agent_selected": "你目前没有相关智能体,但筛选偏好已保留。",
"no_squad_selected": "你目前没有相关小组,但筛选偏好已保留。"
}
},
"list": {
"empty": "暂无通知",
"empty_filtered": "当前筛选下暂无通知",
"empty_no_filter": "未选择任何筛选项",
"show_all": "查看全部",
"mark_done_tooltip": "标为已完成",
"archive_tooltip": "归档",
"time": {

View File

@@ -620,6 +620,8 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Route("/api/inbox", func(r chi.Router) {
r.Get("/", h.ListInbox)
r.Get("/unread-count", h.CountUnreadInbox)
r.Get("/scope-counts", h.GetInboxScopeCounts)
r.Get("/resource-availability", h.GetInboxResourceAvailability)
r.Post("/mark-all-read", h.MarkAllInboxRead)
r.Post("/archive-all", h.ArchiveAllInbox)
r.Post("/archive-all-read", h.ArchiveAllReadInbox)

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
@@ -13,23 +14,88 @@ import (
"github.com/multica-ai/multica/server/pkg/protocol"
)
// Inbox assignment-filter scopes. See RFC v3 §B. The three "my_*" scopes are
// the only user-selectable ones; "other" / "none" exist server-side as
// fallback buckets for the "no filter" mode.
const (
inboxScopeMe = "me"
inboxScopeMyAgent = "my_agent"
inboxScopeMySquad = "my_squad"
inboxScopeOther = "other"
inboxScopeNoneItem = "none"
)
// Operation labels published on `inbox:batch-archived`. The frontend uses
// these to pick the right predicate for precise cache updates (RFC v4 §1).
const (
inboxBatchOpArchiveAll = "archive_all"
inboxBatchOpArchiveRead = "archive_read"
inboxBatchOpArchiveCompleted = "archive_completed"
)
var inboxAssignmentScopes = map[string]bool{
inboxScopeMe: true,
inboxScopeMyAgent: true,
inboxScopeMySquad: true,
}
// parseInboxScope reads `?scope=me,my_agent,my_squad` and validates it.
//
// - Missing / unset → ok=true, scopes=nil (= "no filter", all items).
// - Empty string / empty list → 400 (the frontend's "0 chips selected" state
// must short-circuit before sending the request; reaching here means a
// contract violation).
// - Non-empty → only `me`/`my_agent`/`my_squad` are accepted. `other` / `none`
// are server-internal buckets and cannot be requested explicitly.
func parseInboxScope(w http.ResponseWriter, r *http.Request) (scopes []string, ok bool) {
raw := r.URL.Query().Get("scope")
if raw == "" {
if _, present := r.URL.Query()["scope"]; present {
writeError(w, http.StatusBadRequest, "scope cannot be empty")
return nil, false
}
return nil, true
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if !inboxAssignmentScopes[p] {
writeError(w, http.StatusBadRequest, "invalid scope: "+p)
return nil, false
}
out = append(out, p)
}
if len(out) == 0 {
writeError(w, http.StatusBadRequest, "scope cannot be empty")
return nil, false
}
return out, true
}
type InboxItemResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
RecipientType string `json:"recipient_type"`
RecipientID string `json:"recipient_id"`
Type string `json:"type"`
Severity string `json:"severity"`
IssueID *string `json:"issue_id"`
Title string `json:"title"`
Body *string `json:"body"`
Read bool `json:"read"`
Archived bool `json:"archived"`
CreatedAt string `json:"created_at"`
IssueStatus *string `json:"issue_status"`
ActorType *string `json:"actor_type"`
ActorID *string `json:"actor_id"`
Details json.RawMessage `json:"details"`
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
RecipientType string `json:"recipient_type"`
RecipientID string `json:"recipient_id"`
Type string `json:"type"`
Severity string `json:"severity"`
IssueID *string `json:"issue_id"`
Title string `json:"title"`
Body *string `json:"body"`
Read bool `json:"read"`
Archived bool `json:"archived"`
CreatedAt string `json:"created_at"`
IssueStatus *string `json:"issue_status"`
IssueAssigneeType *string `json:"issue_assignee_type"`
IssueAssigneeID *string `json:"issue_assignee_id"`
AssigneeScope *string `json:"assignee_scope"`
ActorType *string `json:"actor_type"`
ActorID *string `json:"actor_id"`
Details json.RawMessage `json:"details"`
}
func inboxToResponse(i db.InboxItem) InboxItemResponse {
@@ -53,23 +119,27 @@ func inboxToResponse(i db.InboxItem) InboxItemResponse {
}
func inboxRowToResponse(r db.ListInboxItemsRow) InboxItemResponse {
scope := r.AssigneeScope
return InboxItemResponse{
ID: uuidToString(r.ID),
WorkspaceID: uuidToString(r.WorkspaceID),
RecipientType: r.RecipientType,
RecipientID: uuidToString(r.RecipientID),
Type: r.Type,
Severity: r.Severity,
IssueID: uuidToPtr(r.IssueID),
Title: r.Title,
Body: textToPtr(r.Body),
Read: r.Read,
Archived: r.Archived,
CreatedAt: timestampToString(r.CreatedAt),
IssueStatus: textToPtr(r.IssueStatus),
ActorType: textToPtr(r.ActorType),
ActorID: uuidToPtr(r.ActorID),
Details: json.RawMessage(r.Details),
ID: uuidToString(r.ID),
WorkspaceID: uuidToString(r.WorkspaceID),
RecipientType: r.RecipientType,
RecipientID: uuidToString(r.RecipientID),
Type: r.Type,
Severity: r.Severity,
IssueID: uuidToPtr(r.IssueID),
Title: r.Title,
Body: textToPtr(r.Body),
Read: r.Read,
Archived: r.Archived,
CreatedAt: timestampToString(r.CreatedAt),
IssueStatus: textToPtr(r.IssueStatus),
IssueAssigneeType: textToPtr(r.IssueAssigneeType),
IssueAssigneeID: uuidToPtr(r.IssueAssigneeID),
AssigneeScope: &scope,
ActorType: textToPtr(r.ActorType),
ActorID: uuidToPtr(r.ActorID),
Details: json.RawMessage(r.Details),
}
}
@@ -95,11 +165,17 @@ func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
scopes, ok := parseInboxScope(w, r)
if !ok {
return
}
items, err := h.Queries.ListInboxItems(r.Context(), db.ListInboxItemsParams{
WorkspaceID: wsUUID,
RecipientType: "member",
RecipientID: parseUUID(userID),
UserID: parseUUID(userID),
Scopes: scopes,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list inbox")
@@ -195,6 +271,69 @@ func (h *Handler) CountUnreadInbox(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]int64{"count": count})
}
// GetInboxScopeCounts returns post-dedup counts per assignee_scope for the
// current user's inbox. Drives chip badge numbers (RFC v3 §B.3).
func (h *Handler) GetInboxScopeCounts(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := ctxWorkspaceID(r.Context())
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
rows, err := h.Queries.GetInboxScopeCounts(r.Context(), db.GetInboxScopeCountsParams{
WorkspaceID: wsUUID,
UserID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load inbox scope counts")
return
}
counts := map[string]int64{
inboxScopeMe: 0,
inboxScopeMyAgent: 0,
inboxScopeMySquad: 0,
inboxScopeOther: 0,
inboxScopeNoneItem: 0,
}
for _, row := range rows {
counts[row.AssigneeScope] = row.Count
}
writeJSON(w, http.StatusOK, counts)
}
// GetInboxResourceAvailability returns whether the user owns any agent or is
// involved with any squad — used to drive chip enabled/disabled state
// (RFC v3 §B.2.2). Intentionally decoupled from inbox contents so a user with
// 0 squad notifications today is not classified as "has no squad".
func (h *Handler) GetInboxResourceAvailability(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := ctxWorkspaceID(r.Context())
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
row, err := h.Queries.GetInboxResourceAvailability(r.Context(), db.GetInboxResourceAvailabilityParams{
WorkspaceID: wsUUID,
UserID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load inbox resource availability")
return
}
writeJSON(w, http.StatusOK, map[string]bool{
"has_my_agent": row.HasMyAgent,
"has_my_squad": row.HasMySquad,
})
}
func (h *Handler) MarkAllInboxRead(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
@@ -205,84 +344,48 @@ func (h *Handler) MarkAllInboxRead(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
scopes, ok := parseInboxScope(w, r)
if !ok {
return
}
count, err := h.Queries.MarkAllInboxRead(r.Context(), db.MarkAllInboxReadParams{
WorkspaceID: wsUUID,
RecipientID: parseUUID(userID),
UserID: parseUUID(userID),
Scopes: scopes,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark all inbox read")
return
}
slog.Info("inbox: mark all read", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
slog.Info("inbox: mark all read", append(logger.RequestAttrs(r), "user_id", userID, "count", count, "scope", scopes)...)
h.publish(protocol.EventInboxBatchRead, workspaceID, "member", userID, map[string]any{
"recipient_id": userID,
"count": count,
"scope": scopes,
})
writeJSON(w, http.StatusOK, map[string]any{"count": count})
}
func (h *Handler) ArchiveAllInbox(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := ctxWorkspaceID(r.Context())
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
count, err := h.Queries.ArchiveAllInbox(r.Context(), db.ArchiveAllInboxParams{
WorkspaceID: wsUUID,
RecipientID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to archive all inbox")
return
}
slog.Info("inbox: archive all", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
h.publish(protocol.EventInboxBatchArchived, workspaceID, "member", userID, map[string]any{
"recipient_id": userID,
"count": count,
})
writeJSON(w, http.StatusOK, map[string]any{"count": count})
h.archiveAllInboxOp(w, r, inboxBatchOpArchiveAll)
}
func (h *Handler) ArchiveAllReadInbox(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := ctxWorkspaceID(r.Context())
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
count, err := h.Queries.ArchiveAllReadInbox(r.Context(), db.ArchiveAllReadInboxParams{
WorkspaceID: wsUUID,
RecipientID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to archive all read inbox")
return
}
slog.Info("inbox: archive all read", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
h.publish(protocol.EventInboxBatchArchived, workspaceID, "member", userID, map[string]any{
"recipient_id": userID,
"count": count,
})
writeJSON(w, http.StatusOK, map[string]any{"count": count})
h.archiveAllInboxOp(w, r, inboxBatchOpArchiveRead)
}
func (h *Handler) ArchiveCompletedInbox(w http.ResponseWriter, r *http.Request) {
h.archiveAllInboxOp(w, r, inboxBatchOpArchiveCompleted)
}
// archiveAllInboxOp runs the bulk archive variant identified by `operation`
// and emits a single `inbox:batch-archived` event tagged with both the
// operation and the scope filter, so receivers on other devices can update
// their cache without refetching when feasible (RFC v4 §1).
func (h *Handler) archiveAllInboxOp(w http.ResponseWriter, r *http.Request, operation string) {
userID, ok := requireUserID(w, r)
if !ok {
return
@@ -292,20 +395,49 @@ func (h *Handler) ArchiveCompletedInbox(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
count, err := h.Queries.ArchiveCompletedInbox(r.Context(), db.ArchiveCompletedInboxParams{
WorkspaceID: wsUUID,
RecipientID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to archive completed inbox")
scopes, ok := parseInboxScope(w, r)
if !ok {
return
}
slog.Info("inbox: archive completed", append(logger.RequestAttrs(r), "user_id", userID, "count", count)...)
var (
count int64
err error
)
switch operation {
case inboxBatchOpArchiveAll:
count, err = h.Queries.ArchiveAllInbox(r.Context(), db.ArchiveAllInboxParams{
WorkspaceID: wsUUID,
UserID: parseUUID(userID),
Scopes: scopes,
})
case inboxBatchOpArchiveRead:
count, err = h.Queries.ArchiveAllReadInbox(r.Context(), db.ArchiveAllReadInboxParams{
WorkspaceID: wsUUID,
UserID: parseUUID(userID),
Scopes: scopes,
})
case inboxBatchOpArchiveCompleted:
count, err = h.Queries.ArchiveCompletedInbox(r.Context(), db.ArchiveCompletedInboxParams{
WorkspaceID: wsUUID,
UserID: parseUUID(userID),
Scopes: scopes,
})
default:
writeError(w, http.StatusInternalServerError, "unknown inbox batch archive operation")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to archive inbox")
return
}
slog.Info("inbox: "+operation, append(logger.RequestAttrs(r), "user_id", userID, "count", count, "scope", scopes)...)
h.publish(protocol.EventInboxBatchArchived, workspaceID, "member", userID, map[string]any{
"recipient_id": userID,
"count": count,
"operation": operation,
"scope": scopes,
})
writeJSON(w, http.StatusOK, map[string]any{"count": count})

View File

@@ -0,0 +1 @@
DROP FUNCTION IF EXISTS squad_involves_user(UUID, UUID, UUID);

View File

@@ -0,0 +1,48 @@
-- Inbox assignment scope: shared SQL predicate for "the user is involved
-- with this squad" reused by ListIssues (`involves_user_id`) and the inbox
-- assignment filter (chip "我的 Squad"). The three relations match
-- server/pkg/db/queries/issue.sql:29-56 character-for-character so the two
-- callers cannot drift.
--
-- (1) human member of the squad
-- (2) squad.leader_id points at an agent owned by the user
-- (read from squad.leader_id directly — the leader copy in
-- squad_member is best-effort, see squad.go AddSquadMember)
-- (3) squad has an agent member owned by the user
CREATE OR REPLACE FUNCTION squad_involves_user(
p_squad_id UUID,
p_workspace_id UUID,
p_user_id UUID
)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
AS $$
SELECT EXISTS (
SELECT 1
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
WHERE sm.squad_id = p_squad_id
AND s.workspace_id = p_workspace_id
AND sm.member_type = 'member'
AND sm.member_id = p_user_id
UNION ALL
SELECT 1
FROM squad s
JOIN agent a ON a.id = s.leader_id
WHERE s.id = p_squad_id
AND s.workspace_id = p_workspace_id
AND a.workspace_id = p_workspace_id
AND a.owner_id = p_user_id
UNION ALL
SELECT 1
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
JOIN agent a ON a.id = sm.member_id
WHERE sm.squad_id = p_squad_id
AND s.workspace_id = p_workspace_id
AND sm.member_type = 'agent'
AND a.workspace_id = p_workspace_id
AND a.owner_id = p_user_id
);
$$;

View File

@@ -13,16 +13,36 @@ import (
const archiveAllInbox = `-- name: ArchiveAllInbox :execrows
UPDATE inbox_item SET archived = true
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false
WHERE id IN (
SELECT i.id FROM inbox_item i
LEFT JOIN issue iss ON iss.id = i.issue_id
WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $2::uuid
AND i.archived = false
AND (
$3::text[] IS NULL
OR (CASE
WHEN iss.id IS NULL OR iss.assignee_id IS NULL THEN 'none'
WHEN iss.assignee_type = 'member' AND iss.assignee_id = $2::uuid THEN 'me'
WHEN iss.assignee_type = 'agent' AND iss.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = i.workspace_id
AND a.owner_id = $2::uuid
) THEN 'my_agent'
WHEN iss.assignee_type = 'squad' AND squad_involves_user(iss.assignee_id, i.workspace_id, $2::uuid) THEN 'my_squad'
ELSE 'other'
END) = ANY($3::text[])
)
)
`
type ArchiveAllInboxParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
RecipientID pgtype.UUID `json:"recipient_id"`
UserID pgtype.UUID `json:"user_id"`
Scopes []string `json:"scopes"`
}
func (q *Queries) ArchiveAllInbox(ctx context.Context, arg ArchiveAllInboxParams) (int64, error) {
result, err := q.db.Exec(ctx, archiveAllInbox, arg.WorkspaceID, arg.RecipientID)
result, err := q.db.Exec(ctx, archiveAllInbox, arg.WorkspaceID, arg.UserID, arg.Scopes)
if err != nil {
return 0, err
}
@@ -31,16 +51,36 @@ func (q *Queries) ArchiveAllInbox(ctx context.Context, arg ArchiveAllInboxParams
const archiveAllReadInbox = `-- name: ArchiveAllReadInbox :execrows
UPDATE inbox_item SET archived = true
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND read = true AND archived = false
WHERE id IN (
SELECT i.id FROM inbox_item i
LEFT JOIN issue iss ON iss.id = i.issue_id
WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $2::uuid
AND i.archived = false AND i.read = true
AND (
$3::text[] IS NULL
OR (CASE
WHEN iss.id IS NULL OR iss.assignee_id IS NULL THEN 'none'
WHEN iss.assignee_type = 'member' AND iss.assignee_id = $2::uuid THEN 'me'
WHEN iss.assignee_type = 'agent' AND iss.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = i.workspace_id
AND a.owner_id = $2::uuid
) THEN 'my_agent'
WHEN iss.assignee_type = 'squad' AND squad_involves_user(iss.assignee_id, i.workspace_id, $2::uuid) THEN 'my_squad'
ELSE 'other'
END) = ANY($3::text[])
)
)
`
type ArchiveAllReadInboxParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
RecipientID pgtype.UUID `json:"recipient_id"`
UserID pgtype.UUID `json:"user_id"`
Scopes []string `json:"scopes"`
}
func (q *Queries) ArchiveAllReadInbox(ctx context.Context, arg ArchiveAllReadInboxParams) (int64, error) {
result, err := q.db.Exec(ctx, archiveAllReadInbox, arg.WorkspaceID, arg.RecipientID)
result, err := q.db.Exec(ctx, archiveAllReadInbox, arg.WorkspaceID, arg.UserID, arg.Scopes)
if err != nil {
return 0, err
}
@@ -48,18 +88,38 @@ func (q *Queries) ArchiveAllReadInbox(ctx context.Context, arg ArchiveAllReadInb
}
const archiveCompletedInbox = `-- name: ArchiveCompletedInbox :execrows
UPDATE inbox_item i SET archived = true
WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $2 AND i.archived = false
AND i.issue_id IN (SELECT id FROM issue WHERE status IN ('done', 'cancelled'))
UPDATE inbox_item SET archived = true
WHERE id IN (
SELECT i.id FROM inbox_item i
LEFT JOIN issue iss ON iss.id = i.issue_id
WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $2::uuid
AND i.archived = false
AND iss.status IN ('done', 'cancelled')
AND (
$3::text[] IS NULL
OR (CASE
WHEN iss.id IS NULL OR iss.assignee_id IS NULL THEN 'none'
WHEN iss.assignee_type = 'member' AND iss.assignee_id = $2::uuid THEN 'me'
WHEN iss.assignee_type = 'agent' AND iss.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = i.workspace_id
AND a.owner_id = $2::uuid
) THEN 'my_agent'
WHEN iss.assignee_type = 'squad' AND squad_involves_user(iss.assignee_id, i.workspace_id, $2::uuid) THEN 'my_squad'
ELSE 'other'
END) = ANY($3::text[])
)
)
`
type ArchiveCompletedInboxParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
RecipientID pgtype.UUID `json:"recipient_id"`
UserID pgtype.UUID `json:"user_id"`
Scopes []string `json:"scopes"`
}
func (q *Queries) ArchiveCompletedInbox(ctx context.Context, arg ArchiveCompletedInboxParams) (int64, error) {
result, err := q.db.Exec(ctx, archiveCompletedInbox, arg.WorkspaceID, arg.RecipientID)
result, err := q.db.Exec(ctx, archiveCompletedInbox, arg.WorkspaceID, arg.UserID, arg.Scopes)
if err != nil {
return 0, err
}
@@ -294,12 +354,129 @@ func (q *Queries) GetInboxItemInWorkspace(ctx context.Context, arg GetInboxItemI
return i, err
}
const getInboxResourceAvailability = `-- name: GetInboxResourceAvailability :one
SELECT
EXISTS(
SELECT 1 FROM agent a
WHERE a.workspace_id = $1 AND a.owner_id = $2::uuid
) AS has_my_agent,
EXISTS(
SELECT 1 FROM squad s
WHERE s.workspace_id = $1
AND squad_involves_user(s.id, s.workspace_id, $2::uuid)
) AS has_my_squad
`
type GetInboxResourceAvailabilityParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
}
type GetInboxResourceAvailabilityRow struct {
HasMyAgent bool `json:"has_my_agent"`
HasMySquad bool `json:"has_my_squad"`
}
// Drives the chip-disabled state (RFC v3 §B.2.2). Decoupled from inbox content
// so "I belong to a squad but have 0 squad notifications today" does not place
// the chip in the disabled state.
func (q *Queries) GetInboxResourceAvailability(ctx context.Context, arg GetInboxResourceAvailabilityParams) (GetInboxResourceAvailabilityRow, error) {
row := q.db.QueryRow(ctx, getInboxResourceAvailability, arg.WorkspaceID, arg.UserID)
var i GetInboxResourceAvailabilityRow
err := row.Scan(&i.HasMyAgent, &i.HasMySquad)
return i, err
}
const getInboxScopeCounts = `-- name: GetInboxScopeCounts :many
SELECT scoped.assignee_scope, COUNT(DISTINCT COALESCE(scoped.issue_id::text, scoped.id::text))::bigint AS count
FROM (
SELECT i.id AS id,
i.issue_id AS issue_id,
CASE
WHEN iss.id IS NULL OR iss.assignee_id IS NULL THEN 'none'
WHEN iss.assignee_type = 'member' AND iss.assignee_id = $2::uuid THEN 'me'
WHEN iss.assignee_type = 'agent' AND iss.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = i.workspace_id
AND a.owner_id = $2::uuid
) THEN 'my_agent'
WHEN iss.assignee_type = 'squad' AND squad_involves_user(iss.assignee_id, i.workspace_id, $2::uuid) THEN 'my_squad'
ELSE 'other'
END AS assignee_scope
FROM inbox_item i
LEFT JOIN issue iss ON iss.id = i.issue_id
WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $2::uuid AND i.archived = false
) AS scoped
GROUP BY scoped.assignee_scope
`
type GetInboxScopeCountsParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
}
type GetInboxScopeCountsRow struct {
AssigneeScope string `json:"assignee_scope"`
Count int64 `json:"count"`
}
// post-dedup count per scope: an issue with three unread notifications counts once.
// The outer SELECT references `scoped.issue_id` / `scoped.id` explicitly so
// the alias is unambiguous (RFC v3 §B.3 nit).
func (q *Queries) GetInboxScopeCounts(ctx context.Context, arg GetInboxScopeCountsParams) ([]GetInboxScopeCountsRow, error) {
rows, err := q.db.Query(ctx, getInboxScopeCounts, arg.WorkspaceID, arg.UserID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetInboxScopeCountsRow{}
for rows.Next() {
var i GetInboxScopeCountsRow
if err := rows.Scan(&i.AssigneeScope, &i.Count); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listInboxItems = `-- name: ListInboxItems :many
SELECT i.id, i.workspace_id, i.recipient_type, i.recipient_id, i.type, i.severity, i.issue_id, i.title, i.body, i.read, i.archived, i.created_at, i.actor_type, i.actor_id, i.details,
iss.status as issue_status
iss.status AS issue_status,
iss.assignee_type AS issue_assignee_type,
iss.assignee_id AS issue_assignee_id,
CASE
WHEN iss.id IS NULL OR iss.assignee_id IS NULL THEN 'none'
WHEN iss.assignee_type = 'member' AND iss.assignee_id = $4::uuid THEN 'me'
WHEN iss.assignee_type = 'agent' AND iss.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = i.workspace_id
AND a.owner_id = $4::uuid
) THEN 'my_agent'
WHEN iss.assignee_type = 'squad' AND squad_involves_user(iss.assignee_id, i.workspace_id, $4::uuid) THEN 'my_squad'
ELSE 'other'
END AS assignee_scope
FROM inbox_item i
LEFT JOIN issue iss ON iss.id = i.issue_id
WHERE i.workspace_id = $1 AND i.recipient_type = $2 AND i.recipient_id = $3 AND i.archived = false
AND (
$5::text[] IS NULL
OR (CASE
WHEN iss.id IS NULL OR iss.assignee_id IS NULL THEN 'none'
WHEN iss.assignee_type = 'member' AND iss.assignee_id = $4::uuid THEN 'me'
WHEN iss.assignee_type = 'agent' AND iss.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = i.workspace_id
AND a.owner_id = $4::uuid
) THEN 'my_agent'
WHEN iss.assignee_type = 'squad' AND squad_involves_user(iss.assignee_id, i.workspace_id, $4::uuid) THEN 'my_squad'
ELSE 'other'
END) = ANY($5::text[])
)
ORDER BY i.created_at DESC
`
@@ -307,29 +484,47 @@ type ListInboxItemsParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
RecipientType string `json:"recipient_type"`
RecipientID pgtype.UUID `json:"recipient_id"`
UserID pgtype.UUID `json:"user_id"`
Scopes []string `json:"scopes"`
}
type ListInboxItemsRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
RecipientType string `json:"recipient_type"`
RecipientID pgtype.UUID `json:"recipient_id"`
Type string `json:"type"`
Severity string `json:"severity"`
IssueID pgtype.UUID `json:"issue_id"`
Title string `json:"title"`
Body pgtype.Text `json:"body"`
Read bool `json:"read"`
Archived bool `json:"archived"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
ActorType pgtype.Text `json:"actor_type"`
ActorID pgtype.UUID `json:"actor_id"`
Details []byte `json:"details"`
IssueStatus pgtype.Text `json:"issue_status"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
RecipientType string `json:"recipient_type"`
RecipientID pgtype.UUID `json:"recipient_id"`
Type string `json:"type"`
Severity string `json:"severity"`
IssueID pgtype.UUID `json:"issue_id"`
Title string `json:"title"`
Body pgtype.Text `json:"body"`
Read bool `json:"read"`
Archived bool `json:"archived"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
ActorType pgtype.Text `json:"actor_type"`
ActorID pgtype.UUID `json:"actor_id"`
Details []byte `json:"details"`
IssueStatus pgtype.Text `json:"issue_status"`
IssueAssigneeType pgtype.Text `json:"issue_assignee_type"`
IssueAssigneeID pgtype.UUID `json:"issue_assignee_id"`
AssigneeScope string `json:"assignee_scope"`
}
// The `assignee_scope` CASE classifies each inbox row into exactly one of
// five buckets, mirroring the chip semantics in the inbox assignment filter
// (see RFC v3 §B). The three "my_*" branches reuse the same squad/agent
// predicates as ListIssues (server/pkg/db/queries/issue.sql:22-56), so the
// two callers can never drift. `none` covers inbox items without an issue
// or with an unassigned issue; `other` covers issues assigned to someone
// else.
func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams) ([]ListInboxItemsRow, error) {
rows, err := q.db.Query(ctx, listInboxItems, arg.WorkspaceID, arg.RecipientType, arg.RecipientID)
rows, err := q.db.Query(ctx, listInboxItems,
arg.WorkspaceID,
arg.RecipientType,
arg.RecipientID,
arg.UserID,
arg.Scopes,
)
if err != nil {
return nil, err
}
@@ -354,6 +549,9 @@ func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams)
&i.ActorID,
&i.Details,
&i.IssueStatus,
&i.IssueAssigneeType,
&i.IssueAssigneeID,
&i.AssigneeScope,
); err != nil {
return nil, err
}
@@ -367,16 +565,36 @@ func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams)
const markAllInboxRead = `-- name: MarkAllInboxRead :execrows
UPDATE inbox_item SET read = true
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false AND read = false
WHERE id IN (
SELECT i.id FROM inbox_item i
LEFT JOIN issue iss ON iss.id = i.issue_id
WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $2::uuid
AND i.archived = false AND i.read = false
AND (
$3::text[] IS NULL
OR (CASE
WHEN iss.id IS NULL OR iss.assignee_id IS NULL THEN 'none'
WHEN iss.assignee_type = 'member' AND iss.assignee_id = $2::uuid THEN 'me'
WHEN iss.assignee_type = 'agent' AND iss.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = i.workspace_id
AND a.owner_id = $2::uuid
) THEN 'my_agent'
WHEN iss.assignee_type = 'squad' AND squad_involves_user(iss.assignee_id, i.workspace_id, $2::uuid) THEN 'my_squad'
ELSE 'other'
END) = ANY($3::text[])
)
)
`
type MarkAllInboxReadParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
RecipientID pgtype.UUID `json:"recipient_id"`
UserID pgtype.UUID `json:"user_id"`
Scopes []string `json:"scopes"`
}
func (q *Queries) MarkAllInboxRead(ctx context.Context, arg MarkAllInboxReadParams) (int64, error) {
result, err := q.db.Exec(ctx, markAllInboxRead, arg.WorkspaceID, arg.RecipientID)
result, err := q.db.Exec(ctx, markAllInboxRead, arg.WorkspaceID, arg.UserID, arg.Scopes)
if err != nil {
return 0, err
}

View File

@@ -1,9 +1,44 @@
-- The `assignee_scope` CASE classifies each inbox row into exactly one of
-- five buckets, mirroring the chip semantics in the inbox assignment filter
-- (see RFC v3 §B). The three "my_*" branches reuse the same squad/agent
-- predicates as ListIssues (server/pkg/db/queries/issue.sql:22-56), so the
-- two callers can never drift. `none` covers inbox items without an issue
-- or with an unassigned issue; `other` covers issues assigned to someone
-- else.
-- name: ListInboxItems :many
SELECT i.*,
iss.status as issue_status
iss.status AS issue_status,
iss.assignee_type AS issue_assignee_type,
iss.assignee_id AS issue_assignee_id,
CASE
WHEN iss.id IS NULL OR iss.assignee_id IS NULL THEN 'none'
WHEN iss.assignee_type = 'member' AND iss.assignee_id = sqlc.arg('user_id')::uuid THEN 'me'
WHEN iss.assignee_type = 'agent' AND iss.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = i.workspace_id
AND a.owner_id = sqlc.arg('user_id')::uuid
) THEN 'my_agent'
WHEN iss.assignee_type = 'squad' AND squad_involves_user(iss.assignee_id, i.workspace_id, sqlc.arg('user_id')::uuid) THEN 'my_squad'
ELSE 'other'
END AS assignee_scope
FROM inbox_item i
LEFT JOIN issue iss ON iss.id = i.issue_id
WHERE i.workspace_id = $1 AND i.recipient_type = $2 AND i.recipient_id = $3 AND i.archived = false
AND (
sqlc.narg('scopes')::text[] IS NULL
OR (CASE
WHEN iss.id IS NULL OR iss.assignee_id IS NULL THEN 'none'
WHEN iss.assignee_type = 'member' AND iss.assignee_id = sqlc.arg('user_id')::uuid THEN 'me'
WHEN iss.assignee_type = 'agent' AND iss.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = i.workspace_id
AND a.owner_id = sqlc.arg('user_id')::uuid
) THEN 'my_agent'
WHEN iss.assignee_type = 'squad' AND squad_involves_user(iss.assignee_id, i.workspace_id, sqlc.arg('user_id')::uuid) THEN 'my_squad'
ELSE 'other'
END) = ANY(sqlc.narg('scopes')::text[])
)
ORDER BY i.created_at DESC;
-- name: GetInboxItem :one
@@ -47,17 +82,133 @@ WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND read =
-- name: MarkAllInboxRead :execrows
UPDATE inbox_item SET read = true
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false AND read = false;
WHERE id IN (
SELECT i.id FROM inbox_item i
LEFT JOIN issue iss ON iss.id = i.issue_id
WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = sqlc.arg('user_id')::uuid
AND i.archived = false AND i.read = false
AND (
sqlc.narg('scopes')::text[] IS NULL
OR (CASE
WHEN iss.id IS NULL OR iss.assignee_id IS NULL THEN 'none'
WHEN iss.assignee_type = 'member' AND iss.assignee_id = sqlc.arg('user_id')::uuid THEN 'me'
WHEN iss.assignee_type = 'agent' AND iss.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = i.workspace_id
AND a.owner_id = sqlc.arg('user_id')::uuid
) THEN 'my_agent'
WHEN iss.assignee_type = 'squad' AND squad_involves_user(iss.assignee_id, i.workspace_id, sqlc.arg('user_id')::uuid) THEN 'my_squad'
ELSE 'other'
END) = ANY(sqlc.narg('scopes')::text[])
)
);
-- name: ArchiveAllInbox :execrows
UPDATE inbox_item SET archived = true
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false;
WHERE id IN (
SELECT i.id FROM inbox_item i
LEFT JOIN issue iss ON iss.id = i.issue_id
WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = sqlc.arg('user_id')::uuid
AND i.archived = false
AND (
sqlc.narg('scopes')::text[] IS NULL
OR (CASE
WHEN iss.id IS NULL OR iss.assignee_id IS NULL THEN 'none'
WHEN iss.assignee_type = 'member' AND iss.assignee_id = sqlc.arg('user_id')::uuid THEN 'me'
WHEN iss.assignee_type = 'agent' AND iss.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = i.workspace_id
AND a.owner_id = sqlc.arg('user_id')::uuid
) THEN 'my_agent'
WHEN iss.assignee_type = 'squad' AND squad_involves_user(iss.assignee_id, i.workspace_id, sqlc.arg('user_id')::uuid) THEN 'my_squad'
ELSE 'other'
END) = ANY(sqlc.narg('scopes')::text[])
)
);
-- name: ArchiveAllReadInbox :execrows
UPDATE inbox_item SET archived = true
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND read = true AND archived = false;
WHERE id IN (
SELECT i.id FROM inbox_item i
LEFT JOIN issue iss ON iss.id = i.issue_id
WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = sqlc.arg('user_id')::uuid
AND i.archived = false AND i.read = true
AND (
sqlc.narg('scopes')::text[] IS NULL
OR (CASE
WHEN iss.id IS NULL OR iss.assignee_id IS NULL THEN 'none'
WHEN iss.assignee_type = 'member' AND iss.assignee_id = sqlc.arg('user_id')::uuid THEN 'me'
WHEN iss.assignee_type = 'agent' AND iss.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = i.workspace_id
AND a.owner_id = sqlc.arg('user_id')::uuid
) THEN 'my_agent'
WHEN iss.assignee_type = 'squad' AND squad_involves_user(iss.assignee_id, i.workspace_id, sqlc.arg('user_id')::uuid) THEN 'my_squad'
ELSE 'other'
END) = ANY(sqlc.narg('scopes')::text[])
)
);
-- name: ArchiveCompletedInbox :execrows
UPDATE inbox_item i SET archived = true
WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $2 AND i.archived = false
AND i.issue_id IN (SELECT id FROM issue WHERE status IN ('done', 'cancelled'));
UPDATE inbox_item SET archived = true
WHERE id IN (
SELECT i.id FROM inbox_item i
LEFT JOIN issue iss ON iss.id = i.issue_id
WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = sqlc.arg('user_id')::uuid
AND i.archived = false
AND iss.status IN ('done', 'cancelled')
AND (
sqlc.narg('scopes')::text[] IS NULL
OR (CASE
WHEN iss.id IS NULL OR iss.assignee_id IS NULL THEN 'none'
WHEN iss.assignee_type = 'member' AND iss.assignee_id = sqlc.arg('user_id')::uuid THEN 'me'
WHEN iss.assignee_type = 'agent' AND iss.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = i.workspace_id
AND a.owner_id = sqlc.arg('user_id')::uuid
) THEN 'my_agent'
WHEN iss.assignee_type = 'squad' AND squad_involves_user(iss.assignee_id, i.workspace_id, sqlc.arg('user_id')::uuid) THEN 'my_squad'
ELSE 'other'
END) = ANY(sqlc.narg('scopes')::text[])
)
);
-- name: GetInboxScopeCounts :many
-- post-dedup count per scope: an issue with three unread notifications counts once.
-- The outer SELECT references `scoped.issue_id` / `scoped.id` explicitly so
-- the alias is unambiguous (RFC v3 §B.3 nit).
SELECT scoped.assignee_scope, COUNT(DISTINCT COALESCE(scoped.issue_id::text, scoped.id::text))::bigint AS count
FROM (
SELECT i.id AS id,
i.issue_id AS issue_id,
CASE
WHEN iss.id IS NULL OR iss.assignee_id IS NULL THEN 'none'
WHEN iss.assignee_type = 'member' AND iss.assignee_id = sqlc.arg('user_id')::uuid THEN 'me'
WHEN iss.assignee_type = 'agent' AND iss.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = i.workspace_id
AND a.owner_id = sqlc.arg('user_id')::uuid
) THEN 'my_agent'
WHEN iss.assignee_type = 'squad' AND squad_involves_user(iss.assignee_id, i.workspace_id, sqlc.arg('user_id')::uuid) THEN 'my_squad'
ELSE 'other'
END AS assignee_scope
FROM inbox_item i
LEFT JOIN issue iss ON iss.id = i.issue_id
WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = sqlc.arg('user_id')::uuid AND i.archived = false
) AS scoped
GROUP BY scoped.assignee_scope;
-- name: GetInboxResourceAvailability :one
-- Drives the chip-disabled state (RFC v3 §B.2.2). Decoupled from inbox content
-- so "I belong to a squad but have 0 squad notifications today" does not place
-- the chip in the disabled state.
SELECT
EXISTS(
SELECT 1 FROM agent a
WHERE a.workspace_id = $1 AND a.owner_id = sqlc.arg('user_id')::uuid
) AS has_my_agent,
EXISTS(
SELECT 1 FROM squad s
WHERE s.workspace_id = $1
AND squad_involves_user(s.id, s.workspace_id, sqlc.arg('user_id')::uuid)
) AS has_my_squad;