mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
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
142 lines
4.5 KiB
TypeScript
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) });
|
|
},
|
|
});
|
|
}
|