Compare commits

...

1 Commits

Author SHA1 Message Date
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
16 changed files with 655 additions and 3 deletions

View File

@@ -17,6 +17,7 @@ import type {
AgentRunCount,
AgentRuntime,
InboxItem,
NotificationPreference,
IssueSubscriber,
Comment,
Reaction,
@@ -783,6 +784,19 @@ export class ApiClient {
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
}
async listNotificationPreferences(): Promise<NotificationPreference[]> {
return this.fetch("/api/inbox/preferences");
}
async updateNotificationPreferences(
preferences: NotificationPreference[],
): Promise<NotificationPreference[]> {
return this.fetch("/api/inbox/preferences", {
method: "PUT",
body: JSON.stringify({ preferences }),
});
}
// App Config
async getConfig(): Promise<{
cdn_domain: string;

View File

@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { inboxKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { InboxItem } from "../types";
import type { InboxItem, NotificationPreference } from "../types";
export function useMarkInboxRead() {
const qc = useQueryClient();
@@ -111,3 +111,31 @@ export function useArchiveCompletedInbox() {
},
});
}
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) });
},
});
}

View File

@@ -5,6 +5,7 @@ import type { InboxItem } from "../types";
export const inboxKeys = {
all: (wsId: string) => ["inbox", wsId] as const,
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
preferences: (wsId: string) => [...inboxKeys.all(wsId), "preferences"] as const,
};
export function inboxListOptions(wsId: string) {
@@ -57,3 +58,10 @@ export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
}
export function notificationPreferencesOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.preferences(wsId),
queryFn: () => api.listNotificationPreferences(),
});
}

View File

@@ -38,3 +38,8 @@ export interface InboxItem {
created_at: string;
details: Record<string, string> | null;
}
export interface NotificationPreference {
notification_type: InboxItemType;
enabled: boolean;
}

View File

@@ -37,7 +37,7 @@ 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, NotificationPreference } from "./inbox";
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,165 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { notificationPreferencesOptions } from "@multica/core/inbox/queries";
import { useUpdateNotificationPreferences } from "@multica/core/inbox/mutations";
import { toast } from "sonner";
import {
MessageSquare,
UserCheck,
AtSign,
RefreshCw,
ArrowUpDown,
CalendarDays,
Heart,
AlertTriangle,
Sparkles,
} from "lucide-react";
import type { InboxItemType, NotificationPreference } from "@multica/core/types";
import { Switch } from "@multica/ui/components/ui/switch";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogTrigger,
} from "@multica/ui/components/ui/dialog";
import type { ComponentType } from "react";
interface NotificationCategory {
label: string;
description: string;
types: InboxItemType[];
icon: ComponentType<{ className?: string }>;
}
const categories: NotificationCategory[] = [
{
label: "Status changes",
description: "Issue status transitions (e.g. Todo → In Progress)",
types: ["status_changed"],
icon: RefreshCw,
},
{
label: "Comments",
description: "New comments on subscribed issues",
types: ["new_comment"],
icon: MessageSquare,
},
{
label: "Assignments",
description: "Issue assignment and unassignment",
types: ["issue_assigned", "unassigned", "assignee_changed"],
icon: UserCheck,
},
{
label: "Mentions",
description: "When you are @mentioned",
types: ["mentioned"],
icon: AtSign,
},
{
label: "Priority changes",
description: "Issue priority updates",
types: ["priority_changed"],
icon: ArrowUpDown,
},
{
label: "Due date changes",
description: "Issue due date updates",
types: ["due_date_changed"],
icon: CalendarDays,
},
{
label: "Reactions",
description: "Reactions to your issues or comments",
types: ["reaction_added"],
icon: Heart,
},
{
label: "Agent events",
description: "Agent task failures and blocks",
types: ["task_failed", "agent_blocked", "task_completed", "agent_completed"],
icon: AlertTriangle,
},
{
label: "Quick create",
description: "Quick create success and failure results",
types: ["quick_create_done", "quick_create_failed"],
icon: Sparkles,
},
];
function isCategoryEnabled(
prefs: NotificationPreference[],
types: InboxItemType[],
): boolean {
const prefMap = new Map(prefs.map((p) => [p.notification_type, p.enabled]));
return types.every((t) => prefMap.get(t) !== false);
}
export function InboxNotificationSettings({
children,
}: {
children: React.ReactNode;
}) {
const wsId = useWorkspaceId();
const { data: prefs = [] } = useQuery(notificationPreferencesOptions(wsId));
const updateMutation = useUpdateNotificationPreferences();
const handleToggle = (category: NotificationCategory, enabled: boolean) => {
const updates: NotificationPreference[] = category.types.map((t) => ({
notification_type: t,
enabled,
}));
updateMutation.mutate(updates, {
onError: () => toast.error("Failed to update notification settings"),
});
};
return (
<Dialog>
<DialogTrigger render={<>{children}</>} />
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Notification settings</DialogTitle>
<DialogDescription>
Choose which notification types appear in your Inbox.
</DialogDescription>
</DialogHeader>
<div className="space-y-1 -mx-4 max-h-[60vh] overflow-y-auto px-4">
{categories.map((category) => {
const enabled = isCategoryEnabled(prefs, category.types);
return (
<label
key={category.label}
className="flex items-center justify-between gap-3 rounded-md px-2 py-2.5 hover:bg-muted/50 cursor-pointer"
>
<div className="flex items-start gap-3">
<category.icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="space-y-0.5">
<div className="text-sm font-medium leading-none">
{category.label}
</div>
<div className="text-xs text-muted-foreground leading-snug">
{category.description}
</div>
</div>
</div>
<Switch
size="sm"
checked={enabled}
onCheckedChange={(checked) =>
handleToggle(category, checked)
}
/>
</label>
);
})}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -32,6 +32,7 @@ import {
BookCheck,
ListChecks,
ArrowLeft,
Settings,
} from "lucide-react";
import type { InboxItem } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
@@ -53,6 +54,7 @@ import { PageHeader } from "../../layout/page-header";
import { InboxListItem, timeAgo } from "./inbox-list-item";
import { typeLabels } from "./inbox-detail-label";
import { getInboxDisplayTitle } from "./inbox-display";
import { InboxNotificationSettings } from "./inbox-notification-settings";
export function InboxPage() {
const { searchParams, replace } = useNavigation();
@@ -200,7 +202,17 @@ export function InboxPage() {
</span>
)}
</div>
<DropdownMenu>
<div className="flex items-center gap-0.5">
<InboxNotificationSettings>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
>
<Settings className="h-4 w-4" />
</Button>
</InboxNotificationSettings>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
@@ -232,6 +244,7 @@ export function InboxPage() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</PageHeader>
);

View File

@@ -66,6 +66,50 @@ func parseMentions(content string) []mention {
return result
}
// isNotifEnabled checks whether a notification type is enabled for a specific
// user in a workspace. It queries the notification_preference table; if no
// explicit preference exists, it falls back to the default (most types default
// to enabled; status_changed defaults to disabled).
func isNotifEnabled(
ctx context.Context,
queries *db.Queries,
workspaceID string,
userID string,
notifType string,
prefCache map[string]bool,
) bool {
// Check the per-event cache first (populated by preloadNotifPrefs).
if enabled, ok := prefCache[userID]; ok {
return enabled
}
// No explicit preference → use default.
return !handler.DefaultDisabledTypes[notifType]
}
// preloadNotifPrefs loads all explicit preferences for a notification type in
// a workspace into a map[userID]enabled. This avoids per-subscriber queries.
func preloadNotifPrefs(
ctx context.Context,
queries *db.Queries,
workspaceID string,
notifType string,
) map[string]bool {
rows, err := queries.ListNotificationPreferencesByType(ctx, db.ListNotificationPreferencesByTypeParams{
WorkspaceID: parseUUID(workspaceID),
NotificationType: notifType,
})
if err != nil {
slog.Error("failed to load notification preferences",
"workspace_id", workspaceID, "type", notifType, "error", err)
return nil
}
m := make(map[string]bool, len(rows))
for _, r := range rows {
m[util.UUIDToString(r.UserID)] = r.Enabled
}
return m
}
// parentBubbleNotifTypes is the allowlist of inbox notification types that
// bubble up from a sub-issue to subscribers of its parent. Other event types
// only notify subscribers of the sub-issue itself, to keep parent watchers'
@@ -162,6 +206,9 @@ func notifyIssueSubscribers(
return notified
}
// Preload notification preferences for this type (one query for all subscribers).
prefCache := preloadNotifPrefs(ctx, queries, workspaceID, notifType)
for _, sub := range subs {
// Only notify member-type subscribers (not agents)
if sub.UserType != "member" {
@@ -180,6 +227,11 @@ func notifyIssueSubscribers(
continue
}
// Skip if user has disabled this notification type
if !isNotifEnabled(ctx, queries, workspaceID, subID, notifType, prefCache) {
continue
}
item, err := queries.CreateInboxItem(ctx, db.CreateInboxItemParams{
WorkspaceID: parseUUID(workspaceID),
RecipientType: "member",
@@ -237,6 +289,14 @@ func notifyDirect(
return
}
// Skip if recipient has disabled this notification type (members only)
if recipientType == "member" {
prefCache := preloadNotifPrefs(ctx, queries, workspaceID, notifType)
if !isNotifEnabled(ctx, queries, workspaceID, recipientID, notifType, prefCache) {
return
}
}
item, err := queries.CreateInboxItem(ctx, db.CreateInboxItemParams{
WorkspaceID: parseUUID(workspaceID),
RecipientType: recipientType,
@@ -308,10 +368,17 @@ func notifyMentionedMembers(
}
}
// Preload mention preferences
mentionPrefCache := preloadNotifPrefs(context.Background(), queries, e.WorkspaceID, "mentioned")
for id := range recipientIDs {
if id == e.ActorID || skip[id] {
continue
}
// Skip if user has disabled mention notifications
if !isNotifEnabled(context.Background(), queries, e.WorkspaceID, id, "mentioned", mentionPrefCache) {
continue
}
item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
WorkspaceID: parseUUID(e.WorkspaceID),
RecipientType: "member",

View File

@@ -486,6 +486,8 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Post("/archive-completed", h.ArchiveCompletedInbox)
r.Post("/{id}/read", h.MarkInboxRead)
r.Post("/{id}/archive", h.ArchiveInboxItem)
r.Get("/preferences", h.ListNotificationPreferences)
r.Put("/preferences", h.UpdateNotificationPreferences)
})
})
})

View File

@@ -0,0 +1,133 @@
package handler
import (
"encoding/json"
"net/http"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// validNotificationTypes is the set of notification types that users can configure.
var validNotificationTypes = map[string]bool{
"issue_assigned": true,
"unassigned": true,
"assignee_changed": true,
"status_changed": true,
"priority_changed": true,
"due_date_changed": true,
"new_comment": true,
"mentioned": true,
"reaction_added": true,
"task_completed": true,
"task_failed": true,
"agent_blocked": true,
"agent_completed": true,
"quick_create_done": true,
"quick_create_failed": true,
}
// DefaultDisabledTypes are notification types that are OFF by default (no row = disabled).
// All other types default to ON (no row = enabled).
// Exported so notification_listeners can reference the same defaults.
var DefaultDisabledTypes = map[string]bool{
"status_changed": true,
}
type NotificationPreferenceResponse struct {
NotificationType string `json:"notification_type"`
Enabled bool `json:"enabled"`
}
func (h *Handler) ListNotificationPreferences(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
}
prefs, err := h.Queries.ListNotificationPreferences(r.Context(), db.ListNotificationPreferencesParams{
WorkspaceID: wsUUID,
UserID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list notification preferences")
return
}
// Build a map of explicitly set preferences
explicit := make(map[string]bool, len(prefs))
for _, p := range prefs {
explicit[p.NotificationType] = p.Enabled
}
// Build full response with defaults for all valid types
resp := make([]NotificationPreferenceResponse, 0, len(validNotificationTypes))
for t := range validNotificationTypes {
enabled := !DefaultDisabledTypes[t] // default: ON unless in defaultDisabledTypes
if v, ok := explicit[t]; ok {
enabled = v
}
resp = append(resp, NotificationPreferenceResponse{
NotificationType: t,
Enabled: enabled,
})
}
writeJSON(w, http.StatusOK, resp)
}
type updateNotificationPreferencesRequest struct {
Preferences []NotificationPreferenceResponse `json:"preferences"`
}
func (h *Handler) UpdateNotificationPreferences(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
}
var req updateNotificationPreferencesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if len(req.Preferences) == 0 {
writeError(w, http.StatusBadRequest, "preferences cannot be empty")
return
}
// Validate all notification types
for _, p := range req.Preferences {
if !validNotificationTypes[p.NotificationType] {
writeError(w, http.StatusBadRequest, "invalid notification type: "+p.NotificationType)
return
}
}
// Upsert each preference
for _, p := range req.Preferences {
_, err := h.Queries.UpsertNotificationPreference(r.Context(), db.UpsertNotificationPreferenceParams{
WorkspaceID: wsUUID,
UserID: parseUUID(userID),
NotificationType: p.NotificationType,
Enabled: p.Enabled,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update notification preference")
return
}
}
// Return updated preferences
h.ListNotificationPreferences(w, r)
}

View File

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

View File

@@ -0,0 +1,12 @@
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,
notification_type TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(workspace_id, user_id, notification_type)
);
CREATE INDEX idx_notification_preference_user ON notification_preference(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,16 @@ 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"`
NotificationType string `json:"notification_type"`
Enabled bool `json:"enabled"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
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,166 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: notification_preferences.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteNotificationPreferences = `-- name: DeleteNotificationPreferences :exec
DELETE FROM notification_preference
WHERE workspace_id = $1 AND user_id = $2
`
type DeleteNotificationPreferencesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) DeleteNotificationPreferences(ctx context.Context, arg DeleteNotificationPreferencesParams) error {
_, err := q.db.Exec(ctx, deleteNotificationPreferences, arg.WorkspaceID, arg.UserID)
return err
}
const getDisabledNotificationTypes = `-- name: GetDisabledNotificationTypes :many
SELECT notification_type FROM notification_preference
WHERE workspace_id = $1 AND user_id = $2 AND enabled = false
`
type GetDisabledNotificationTypesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) GetDisabledNotificationTypes(ctx context.Context, arg GetDisabledNotificationTypesParams) ([]string, error) {
rows, err := q.db.Query(ctx, getDisabledNotificationTypes, arg.WorkspaceID, arg.UserID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var notification_type string
if err := rows.Scan(&notification_type); err != nil {
return nil, err
}
items = append(items, notification_type)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNotificationPreferences = `-- name: ListNotificationPreferences :many
SELECT id, workspace_id, user_id, notification_type, enabled, created_at, updated_at FROM notification_preference
WHERE workspace_id = $1 AND user_id = $2
ORDER BY notification_type
`
type ListNotificationPreferencesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
}
func (q *Queries) ListNotificationPreferences(ctx context.Context, arg ListNotificationPreferencesParams) ([]NotificationPreference, error) {
rows, err := q.db.Query(ctx, listNotificationPreferences, arg.WorkspaceID, arg.UserID)
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.NotificationType,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listNotificationPreferencesByType = `-- name: ListNotificationPreferencesByType :many
SELECT user_id, enabled FROM notification_preference
WHERE workspace_id = $1 AND notification_type = $2
`
type ListNotificationPreferencesByTypeParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
NotificationType string `json:"notification_type"`
}
type ListNotificationPreferencesByTypeRow struct {
UserID pgtype.UUID `json:"user_id"`
Enabled bool `json:"enabled"`
}
func (q *Queries) ListNotificationPreferencesByType(ctx context.Context, arg ListNotificationPreferencesByTypeParams) ([]ListNotificationPreferencesByTypeRow, error) {
rows, err := q.db.Query(ctx, listNotificationPreferencesByType, arg.WorkspaceID, arg.NotificationType)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListNotificationPreferencesByTypeRow{}
for rows.Next() {
var i ListNotificationPreferencesByTypeRow
if err := rows.Scan(&i.UserID, &i.Enabled); 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, notification_type, enabled)
VALUES ($1, $2, $3, $4)
ON CONFLICT (workspace_id, user_id, notification_type)
DO UPDATE SET enabled = $4, updated_at = now()
RETURNING id, workspace_id, user_id, notification_type, enabled, created_at, updated_at
`
type UpsertNotificationPreferenceParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
NotificationType string `json:"notification_type"`
Enabled bool `json:"enabled"`
}
func (q *Queries) UpsertNotificationPreference(ctx context.Context, arg UpsertNotificationPreferenceParams) (NotificationPreference, error) {
row := q.db.QueryRow(ctx, upsertNotificationPreference,
arg.WorkspaceID,
arg.UserID,
arg.NotificationType,
arg.Enabled,
)
var i NotificationPreference
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.UserID,
&i.NotificationType,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@@ -0,0 +1,23 @@
-- name: ListNotificationPreferences :many
SELECT * FROM notification_preference
WHERE workspace_id = $1 AND user_id = $2
ORDER BY notification_type;
-- name: UpsertNotificationPreference :one
INSERT INTO notification_preference (workspace_id, user_id, notification_type, enabled)
VALUES ($1, $2, $3, $4)
ON CONFLICT (workspace_id, user_id, notification_type)
DO UPDATE SET enabled = $4, updated_at = now()
RETURNING *;
-- name: GetDisabledNotificationTypes :many
SELECT notification_type FROM notification_preference
WHERE workspace_id = $1 AND user_id = $2 AND enabled = false;
-- name: ListNotificationPreferencesByType :many
SELECT user_id, enabled FROM notification_preference
WHERE workspace_id = $1 AND notification_type = $2;
-- name: DeleteNotificationPreferences :exec
DELETE FROM notification_preference
WHERE workspace_id = $1 AND user_id = $2;