mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
1 Commits
quick-crea
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45e1dfd325 |
@@ -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;
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,3 +38,8 @@ export interface InboxItem {
|
||||
created_at: string;
|
||||
details: Record<string, string> | null;
|
||||
}
|
||||
|
||||
export interface NotificationPreference {
|
||||
notification_type: InboxItemType;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
165
packages/views/inbox/components/inbox-notification-settings.tsx
Normal file
165
packages/views/inbox/components/inbox-notification-settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
133
server/internal/handler/notification_preferences.go
Normal file
133
server/internal/handler/notification_preferences.go
Normal 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)
|
||||
}
|
||||
1
server/migrations/064_notification_preferences.down.sql
Normal file
1
server/migrations/064_notification_preferences.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS notification_preference;
|
||||
12
server/migrations/064_notification_preferences.up.sql
Normal file
12
server/migrations/064_notification_preferences.up.sql
Normal 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);
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
166
server/pkg/db/generated/notification_preferences.sql.go
Normal file
166
server/pkg/db/generated/notification_preferences.sql.go
Normal 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(¬ification_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
|
||||
}
|
||||
23
server/pkg/db/queries/notification_preferences.sql
Normal file
23
server/pkg/db/queries/notification_preferences.sql
Normal 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;
|
||||
Reference in New Issue
Block a user