Files
multica/packages/core/inbox/mutations.ts
Jiayuan 45e1dfd325 feat(inbox): add notification preferences settings
Add per-type notification preference controls to the Inbox, allowing
users to toggle which notification types they receive. This addresses
the core pain point of agent-generated status change noise flooding
user inboxes.

Backend:
- New `notification_preference` table (migration 064) with per-user,
  per-workspace, per-type enabled/disabled toggle
- GET/PUT /api/inbox/preferences endpoints for CRUD
- notification_listeners.go checks user preferences before creating
  inbox items, using preloaded per-type batch queries for efficiency
- status_changed notifications default to OFF; all others default ON

Frontend:
- NotificationPreference type and API client methods
- TanStack Query options + optimistic mutation hook
- Settings dialog accessible via gear icon in Inbox header
- Grouped by category: Status, Comments, Assignments, Mentions,
  Priority, Due dates, Reactions, Agent events, Quick create
- Each category shows icon, label, description, and Switch toggle
2026-04-29 21:10:00 +02:00

142 lines
4.5 KiB
TypeScript

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { inboxKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { InboxItem, NotificationPreference } from "../types";
export function useMarkInboxRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.markInboxRead(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) => (item.id === id ? { ...item, read: true } : item)),
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useArchiveInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.archiveInbox(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
// Archive all items for the same issue (same behavior as store)
const target = prev?.find((i) => i.id === id);
const issueId = target?.issue_id;
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) =>
item.id === id || (issueId && item.issue_id === issueId)
? { ...item, archived: true }
: item,
),
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useMarkAllInboxRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.markAllInboxRead(),
onMutate: async () => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) =>
!item.archived ? { ...item, read: true } : item,
),
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useArchiveAllInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useArchiveAllReadInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllReadInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useArchiveCompletedInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveCompletedInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useUpdateNotificationPreferences() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (preferences: NotificationPreference[]) =>
api.updateNotificationPreferences(preferences),
onMutate: async (preferences) => {
await qc.cancelQueries({ queryKey: inboxKeys.preferences(wsId) });
const prev = qc.getQueryData<NotificationPreference[]>(inboxKeys.preferences(wsId));
qc.setQueryData<NotificationPreference[]>(inboxKeys.preferences(wsId), (old) => {
if (!old) return preferences;
const map = new Map(old.map((p) => [p.notification_type, p]));
for (const p of preferences) {
map.set(p.notification_type, p);
}
return Array.from(map.values());
});
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.preferences(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.preferences(wsId) });
},
});
}