Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan
42633a8d6c feat(inbox): add notification preferences to control inbox noise by event type
Users can now mute specific notification categories (assignments, status
changes, comments & mentions, priority/due-date updates, agent activity)
from Settings > Notifications. Muted event types are silently filtered at
notification creation time — no inbox items are created for muted groups.

- Add notification_preference table (migration 064)
- Add GET/PUT /api/notification-preferences endpoints
- Filter notifications in notifyIssueSubscribers, notifyDirect, and
  notifyMentionedMembers based on user preferences
- Add Notifications tab in Settings with per-group toggle switches
2026-04-29 22:31:57 +02:00
18 changed files with 556 additions and 1 deletions

View File

@@ -75,6 +75,8 @@ import type {
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
NotificationPreferenceResponse,
NotificationPreferences,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
@@ -783,6 +785,18 @@ export class ApiClient {
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
}
// Notification preferences
async getNotificationPreferences(): Promise<NotificationPreferenceResponse> {
return this.fetch("/api/notification-preferences");
}
async updateNotificationPreferences(preferences: NotificationPreferences): Promise<NotificationPreferenceResponse> {
return this.fetch("/api/notification-preferences", {
method: "PUT",
body: JSON.stringify({ preferences }),
});
}
// App Config
async getConfig(): Promise<{
cdn_domain: string;

View File

@@ -0,0 +1,2 @@
export * from "./queries";
export * from "./mutations";

View File

@@ -0,0 +1,34 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useWorkspaceId } from "../hooks";
import { notificationPreferenceKeys } from "./queries";
import type { NotificationPreferences, NotificationPreferenceResponse } from "../types";
export function useUpdateNotificationPreferences() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (preferences: NotificationPreferences) =>
api.updateNotificationPreferences(preferences),
onMutate: async (preferences) => {
await qc.cancelQueries({ queryKey: notificationPreferenceKeys.all(wsId) });
const prev = qc.getQueryData<NotificationPreferenceResponse>(
notificationPreferenceKeys.all(wsId),
);
qc.setQueryData<NotificationPreferenceResponse>(
notificationPreferenceKeys.all(wsId),
(old) => old ? { ...old, preferences } : { workspace_id: wsId, preferences },
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) {
qc.setQueryData(notificationPreferenceKeys.all(wsId), ctx.prev);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: notificationPreferenceKeys.all(wsId) });
},
});
}

View File

@@ -0,0 +1,13 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const notificationPreferenceKeys = {
all: (wsId: string) => ["notification-preferences", wsId] as const,
};
export function notificationPreferenceOptions(wsId: string) {
return queryOptions({
queryKey: notificationPreferenceKeys.all(wsId),
queryFn: () => api.getNotificationPreferences(),
});
}

View File

@@ -35,6 +35,9 @@
"./inbox/queries": "./inbox/queries.ts",
"./inbox/mutations": "./inbox/mutations.ts",
"./inbox/ws-updaters": "./inbox/ws-updaters.ts",
"./notification-preferences": "./notification-preferences/index.ts",
"./notification-preferences/queries": "./notification-preferences/queries.ts",
"./notification-preferences/mutations": "./notification-preferences/mutations.ts",
"./chat": "./chat/index.ts",
"./chat/queries": "./chat/queries.ts",
"./chat/mutations": "./chat/mutations.ts",

View File

@@ -38,6 +38,7 @@ export type {
} from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } 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";
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";

View File

@@ -0,0 +1,15 @@
export type NotificationGroupKey =
| "assignments"
| "status_changes"
| "comments"
| "updates"
| "agent_activity";
export type NotificationGroupValue = "all" | "muted";
export type NotificationPreferences = Partial<Record<NotificationGroupKey, NotificationGroupValue>>;
export interface NotificationPreferenceResponse {
workspace_id: string;
preferences: NotificationPreferences;
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { notificationPreferenceOptions } from "@multica/core/notification-preferences/queries";
import { useUpdateNotificationPreferences } from "@multica/core/notification-preferences/mutations";
import type { NotificationGroupKey, NotificationPreferences } from "@multica/core/types";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Switch } from "@multica/ui/components/ui/switch";
import { toast } from "sonner";
const notificationGroups: {
key: NotificationGroupKey;
label: string;
description: string;
}[] = [
{
key: "assignments",
label: "Assignments",
description: "When you are assigned or unassigned from an issue",
},
{
key: "status_changes",
label: "Status changes",
description: "When an issue you follow changes status (e.g. todo, in progress, done)",
},
{
key: "comments",
label: "Comments & Mentions",
description: "New comments on issues you follow, or when someone @mentions you",
},
{
key: "updates",
label: "Priority & Due date",
description: "When priority or due date changes on issues you follow",
},
{
key: "agent_activity",
label: "Agent activity",
description: "When an agent task completes or fails",
},
];
export function NotificationsTab() {
const wsId = useWorkspaceId();
const { data } = useQuery(notificationPreferenceOptions(wsId));
const mutation = useUpdateNotificationPreferences();
const preferences = data?.preferences ?? {};
const handleToggle = (key: NotificationGroupKey, enabled: boolean) => {
const updated: NotificationPreferences = {
...preferences,
[key]: enabled ? "all" : "muted",
};
// Remove keys set to "all" (default) to keep the object clean
if (enabled) {
delete updated[key];
}
mutation.mutate(updated, {
onError: () => toast.error("Failed to update notification settings"),
});
};
return (
<div className="space-y-4">
<section className="space-y-4">
<div>
<h2 className="text-sm font-semibold">Inbox Notifications</h2>
<p className="text-sm text-muted-foreground mt-1">
Control which events generate inbox notifications. Muted event types
are silently filtered you can still see them by visiting the issue
directly.
</p>
</div>
<Card>
<CardContent className="divide-y">
{notificationGroups.map((group) => {
const enabled = preferences[group.key] !== "muted";
return (
<div
key={group.key}
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
>
<div className="space-y-0.5 pr-4">
<p className="text-sm font-medium">{group.label}</p>
<p className="text-xs text-muted-foreground">
{group.description}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={(checked) =>
handleToggle(group.key, checked)
}
/>
</div>
);
})}
</CardContent>
</Card>
</section>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { User, Palette, Key, Settings, Users, FolderGit2, FlaskConical } from "lucide-react";
import { User, Palette, Key, Settings, Users, FolderGit2, FlaskConical, Bell } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs";
import { useCurrentWorkspace } from "@multica/core/paths";
import { AccountTab } from "./account-tab";
@@ -11,10 +11,12 @@ import { WorkspaceTab } from "./workspace-tab";
import { MembersTab } from "./members-tab";
import { RepositoriesTab } from "./repositories-tab";
import { LabsTab } from "./labs-tab";
import { NotificationsTab } from "./notifications-tab";
const accountTabs = [
{ value: "profile", label: "Profile", icon: User },
{ value: "appearance", label: "Appearance", icon: Palette },
{ value: "notifications", label: "Notifications", icon: Bell },
{ value: "tokens", label: "API Tokens", icon: Key },
];
@@ -81,6 +83,7 @@ export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) {
<div className="w-full max-w-3xl mx-auto p-6">
<TabsContent value="profile"><AccountTab /></TabsContent>
<TabsContent value="appearance"><AppearanceTab /></TabsContent>
<TabsContent value="notifications"><NotificationsTab /></TabsContent>
<TabsContent value="tokens"><TokensTab /></TabsContent>
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
<TabsContent value="repositories"><RepositoriesTab /></TabsContent>

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"log/slog"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/handler"
"github.com/multica-ai/multica/server/internal/util"
@@ -74,6 +75,70 @@ var parentBubbleNotifTypes = map[string]bool{
"status_changed": true,
}
// notifTypeToGroup maps each InboxItemType to a user-configurable preference
// group. Types not in this map are always delivered (not configurable).
var notifTypeToGroup = map[string]string{
"issue_assigned": "assignments",
"unassigned": "assignments",
"assignee_changed": "assignments",
"status_changed": "status_changes",
"new_comment": "comments",
"mentioned": "comments",
"priority_changed": "updates",
"due_date_changed": "updates",
"task_completed": "agent_activity",
"task_failed": "agent_activity",
"agent_blocked": "agent_activity",
"agent_completed": "agent_activity",
}
// isNotifMuted returns true if the given notification type is muted for a user
// based on their parsed preferences map.
func isNotifMuted(prefs map[string]string, notifType string) bool {
group, ok := notifTypeToGroup[notifType]
if !ok {
return false // unconfigurable types are always delivered
}
return prefs[group] == "muted"
}
// loadUserPrefs loads notification preferences for a set of user IDs in a
// workspace. Returns a map from user_id string to parsed preferences.
func loadUserPrefs(
ctx context.Context,
queries *db.Queries,
workspaceID string,
userIDs []string,
) map[string]map[string]string {
if len(userIDs) == 0 {
return nil
}
uuids := make([]pgtype.UUID, len(userIDs))
for i, id := range userIDs {
uuids[i] = parseUUID(id)
}
rows, err := queries.ListNotificationPreferencesByUsers(ctx, db.ListNotificationPreferencesByUsersParams{
WorkspaceID: parseUUID(workspaceID),
UserIds: uuids,
})
if err != nil {
slog.Error("failed to load notification preferences", "error", err)
return nil
}
result := make(map[string]map[string]string, len(rows))
for _, row := range rows {
var prefs map[string]string
if err := json.Unmarshal(row.Preferences, &prefs); err != nil {
continue
}
result[util.UUIDToString(row.UserID)] = prefs
}
return result
}
// notifySubscribers queries the subscriber table for an issue, excludes the
// actor and any extra IDs, and creates inbox items for each remaining member
// subscriber. Publishes an inbox:new event for each notification.
@@ -162,6 +227,15 @@ func notifyIssueSubscribers(
return notified
}
// Batch-load notification preferences for all member subscribers.
var memberIDs []string
for _, sub := range subs {
if sub.UserType == "member" {
memberIDs = append(memberIDs, util.UUIDToString(sub.UserID))
}
}
userPrefs := loadUserPrefs(ctx, queries, workspaceID, memberIDs)
for _, sub := range subs {
// Only notify member-type subscribers (not agents)
if sub.UserType != "member" {
@@ -180,6 +254,11 @@ func notifyIssueSubscribers(
continue
}
// Skip if this notification type is muted by the user
if prefs, ok := userPrefs[subID]; ok && isNotifMuted(prefs, notifType) {
continue
}
item, err := queries.CreateInboxItem(ctx, db.CreateInboxItemParams{
WorkspaceID: parseUUID(workspaceID),
RecipientType: "member",
@@ -237,6 +316,14 @@ func notifyDirect(
return
}
// Check notification preferences for member recipients.
if recipientType == "member" {
prefs := loadUserPrefs(ctx, queries, workspaceID, []string{recipientID})
if p, ok := prefs[recipientID]; ok && isNotifMuted(p, notifType) {
return
}
}
item, err := queries.CreateInboxItem(ctx, db.CreateInboxItemParams{
WorkspaceID: parseUUID(workspaceID),
RecipientType: recipientType,
@@ -308,10 +395,23 @@ func notifyMentionedMembers(
}
}
// Batch-load notification preferences for all mention recipients.
var mentionUserIDs []string
for id := range recipientIDs {
if id != e.ActorID && !skip[id] {
mentionUserIDs = append(mentionUserIDs, id)
}
}
mentionPrefs := loadUserPrefs(context.Background(), queries, e.WorkspaceID, mentionUserIDs)
for id := range recipientIDs {
if id == e.ActorID || skip[id] {
continue
}
// Skip if mentions/comments are muted by this user
if p, ok := mentionPrefs[id]; ok && isNotifMuted(p, "mentioned") {
continue
}
item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
WorkspaceID: parseUUID(e.WorkspaceID),
RecipientType: "member",

View File

@@ -487,6 +487,12 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Post("/{id}/read", h.MarkInboxRead)
r.Post("/{id}/archive", h.ArchiveInboxItem)
})
// Notification preferences
r.Route("/api/notification-preferences", func(r chi.Router) {
r.Get("/", h.GetNotificationPreferences)
r.Put("/", h.UpdateNotificationPreferences)
})
})
})

View File

@@ -0,0 +1,124 @@
package handler
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/jackc/pgx/v5"
"github.com/multica-ai/multica/server/internal/logger"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// validNotifGroups is the set of notification preference group keys that the
// API accepts. Keys not in this set are rejected.
var validNotifGroups = map[string]bool{
"assignments": true,
"status_changes": true,
"comments": true,
"updates": true,
"agent_activity": true,
}
// validNotifValues is the set of allowed preference values per group.
var validNotifValues = map[string]bool{
"all": true,
"muted": true,
}
func (h *Handler) GetNotificationPreferences(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := ctxWorkspaceID(r.Context())
pref, err := h.Queries.GetNotificationPreference(r.Context(), db.GetNotificationPreferenceParams{
WorkspaceID: parseUUID(workspaceID),
UserID: parseUUID(userID),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeJSON(w, http.StatusOK, map[string]any{
"workspace_id": workspaceID,
"preferences": map[string]any{},
})
return
}
slog.Warn("GetNotificationPreference failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to get notification preferences")
return
}
var prefs map[string]string
if err := json.Unmarshal(pref.Preferences, &prefs); err != nil {
prefs = map[string]string{}
}
writeJSON(w, http.StatusOK, map[string]any{
"workspace_id": workspaceID,
"preferences": prefs,
})
}
type updateNotifPrefRequest struct {
Preferences map[string]string `json:"preferences"`
}
func (h *Handler) UpdateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := ctxWorkspaceID(r.Context())
var req updateNotifPrefRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Preferences == nil {
writeError(w, http.StatusBadRequest, "preferences field is required")
return
}
for k, v := range req.Preferences {
if !validNotifGroups[k] {
writeError(w, http.StatusBadRequest, "invalid preference group: "+k)
return
}
if !validNotifValues[v] {
writeError(w, http.StatusBadRequest, "invalid preference value: "+v)
return
}
}
prefsJSON, err := json.Marshal(req.Preferences)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to marshal preferences")
return
}
pref, err := h.Queries.UpsertNotificationPreference(r.Context(), db.UpsertNotificationPreferenceParams{
WorkspaceID: parseUUID(workspaceID),
UserID: parseUUID(userID),
Preferences: prefsJSON,
})
if err != nil {
slog.Warn("UpsertNotificationPreference failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to update notification preferences")
return
}
var prefs map[string]string
if err := json.Unmarshal(pref.Preferences, &prefs); err != nil {
prefs = map[string]string{}
}
writeJSON(w, http.StatusOK, map[string]any{
"workspace_id": workspaceID,
"preferences": prefs,
})
}

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS notification_preference;

View File

@@ -0,0 +1,8 @@
CREATE TABLE notification_preference (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
preferences JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(workspace_id, user_id)
);

View File

@@ -53,6 +53,11 @@ type DeleteDaemonTokensByWorkspaceAndDaemonParams struct {
DaemonID string `json:"daemon_id"`
}
// Callers MUST also invalidate auth.DaemonTokenCache for each affected
// token_hash so the deletion takes effect before the cache TTL expires.
// Today this query has no caller; when a deregister / rotate flow lands,
// change this to :many RETURNING token_hash and call
// DaemonTokenCache.Invalidate(hash) for each row.
func (q *Queries) DeleteDaemonTokensByWorkspaceAndDaemon(ctx context.Context, arg DeleteDaemonTokensByWorkspaceAndDaemonParams) error {
_, err := q.db.Exec(ctx, deleteDaemonTokensByWorkspaceAndDaemon, arg.WorkspaceID, arg.DaemonID)
return err

View File

@@ -321,6 +321,14 @@ type Member struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type NotificationPreference struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
Preferences []byte `json:"preferences"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type PersonalAccessToken struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`

View File

@@ -0,0 +1,98 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: notification_preference.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getNotificationPreference = `-- name: GetNotificationPreference :one
SELECT id, workspace_id, user_id, preferences, updated_at FROM notification_preference
WHERE workspace_id = $1 AND user_id = $2
`
type GetNotificationPreferenceParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) GetNotificationPreference(ctx context.Context, arg GetNotificationPreferenceParams) (NotificationPreference, error) {
row := q.db.QueryRow(ctx, getNotificationPreference, arg.WorkspaceID, arg.UserID)
var i NotificationPreference
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.UserID,
&i.Preferences,
&i.UpdatedAt,
)
return i, err
}
const listNotificationPreferencesByUsers = `-- name: ListNotificationPreferencesByUsers :many
SELECT id, workspace_id, user_id, preferences, updated_at FROM notification_preference
WHERE workspace_id = $1 AND user_id = ANY($2::uuid[])
`
type ListNotificationPreferencesByUsersParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserIds []pgtype.UUID `json:"user_ids"`
}
func (q *Queries) ListNotificationPreferencesByUsers(ctx context.Context, arg ListNotificationPreferencesByUsersParams) ([]NotificationPreference, error) {
rows, err := q.db.Query(ctx, listNotificationPreferencesByUsers, arg.WorkspaceID, arg.UserIds)
if err != nil {
return nil, err
}
defer rows.Close()
items := []NotificationPreference{}
for rows.Next() {
var i NotificationPreference
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.UserID,
&i.Preferences,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertNotificationPreference = `-- name: UpsertNotificationPreference :one
INSERT INTO notification_preference (workspace_id, user_id, preferences)
VALUES ($1, $2, $3)
ON CONFLICT (workspace_id, user_id)
DO UPDATE SET preferences = $3, updated_at = now()
RETURNING id, workspace_id, user_id, preferences, updated_at
`
type UpsertNotificationPreferenceParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
Preferences []byte `json:"preferences"`
}
func (q *Queries) UpsertNotificationPreference(ctx context.Context, arg UpsertNotificationPreferenceParams) (NotificationPreference, error) {
row := q.db.QueryRow(ctx, upsertNotificationPreference, arg.WorkspaceID, arg.UserID, arg.Preferences)
var i NotificationPreference
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.UserID,
&i.Preferences,
&i.UpdatedAt,
)
return i, err
}

View File

@@ -0,0 +1,14 @@
-- name: GetNotificationPreference :one
SELECT * FROM notification_preference
WHERE workspace_id = $1 AND user_id = $2;
-- name: UpsertNotificationPreference :one
INSERT INTO notification_preference (workspace_id, user_id, preferences)
VALUES ($1, $2, $3)
ON CONFLICT (workspace_id, user_id)
DO UPDATE SET preferences = $3, updated_at = now()
RETURNING *;
-- name: ListNotificationPreferencesByUsers :many
SELECT * FROM notification_preference
WHERE workspace_id = $1 AND user_id = ANY(sqlc.arg('user_ids')::uuid[]);