mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
3 Commits
refactor/e
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
117c7ba6ae | ||
|
|
7ac797fcd8 | ||
|
|
fd913a2596 |
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./queries";
|
||||
export * from "./mutations";
|
||||
export * from "./ws-updaters";
|
||||
export * from "./stores";
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
83
packages/core/inbox/stores/inbox-scope-store.ts
Normal file
83
packages/core/inbox/stores/inbox-scope-store.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
7
packages/core/inbox/stores/index.ts
Normal file
7
packages/core/inbox/stores/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
useInboxScopeStore,
|
||||
resolveInboxFilter,
|
||||
INBOX_FILTER_SCOPES,
|
||||
type InboxFilterMode,
|
||||
type InboxFilterResolution,
|
||||
} from "./inbox-scope-store";
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
113
packages/views/inbox/components/inbox-filter-chips.tsx
Normal file
113
packages/views/inbox/components/inbox-filter-chips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
1
server/migrations/095_inbox_assignee_scope.down.sql
Normal file
1
server/migrations/095_inbox_assignee_scope.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP FUNCTION IF EXISTS squad_involves_user(UUID, UUID, UUID);
|
||||
48
server/migrations/095_inbox_assignee_scope.up.sql
Normal file
48
server/migrations/095_inbox_assignee_scope.up.sql
Normal 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
|
||||
);
|
||||
$$;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user