Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan
117fb78013 fix(agents): navigate to detail page before invalidating list query
After creating an agent from the empty state, the query invalidation
triggered a refetch that re-rendered the agents list page (empty → list)
before navigation to the detail page completed, causing a visible flash.

Move navigation.push() before qc.invalidateQueries() so the user lands
on the detail page immediately; the list refetch happens in the
background after we've already left.
2026-04-29 18:18:37 +02:00
19 changed files with 14 additions and 716 deletions

View File

@@ -111,22 +111,6 @@ function createWindow(): void {
return { action: "deny" };
});
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
// reloading the page. In a desktop app an accidental reload destroys
// in-memory state (tabs, drafts, WS connections) with no URL bar to
// navigate back. DevTools refresh (via the DevTools UI) still works.
mainWindow.webContents.on("before-input-event", (_event, input) => {
if (input.type !== "keyDown") return;
const cmdOrCtrl =
process.platform === "darwin" ? input.meta : input.control;
if (
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
input.key === "F5"
) {
_event.preventDefault();
}
});
installContextMenu(mainWindow.webContents);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {

View File

@@ -17,7 +17,6 @@ import type {
AgentRunCount,
AgentRuntime,
InboxItem,
NotificationPreference,
IssueSubscriber,
Comment,
Reaction,
@@ -784,19 +783,6 @@ 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, NotificationPreference } from "../types";
import type { InboxItem } from "../types";
export function useMarkInboxRead() {
const qc = useQueryClient();
@@ -111,31 +111,3 @@ 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,7 +5,6 @@ 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) {
@@ -58,10 +57,3 @@ 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,8 +38,3 @@ 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, NotificationPreference } from "./inbox";
export type { InboxItem, InboxSeverity, InboxItemType } 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

@@ -44,9 +44,7 @@ import {
} from "./trigger-config";
import type { TriggerConfig } from "./trigger-config";
import type { AutopilotExecutionMode, AutopilotRun, AutopilotTrigger } from "@multica/core/types";
import type { AgentTask } from "@multica/core/types/agent";
import { ReadonlyContent } from "../../editor";
import { TranscriptButton } from "../../common/task-transcript";
import { AutopilotDialog } from "./autopilot-dialog";
function formatDate(date: string): string {
@@ -65,34 +63,11 @@ const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: ty
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
};
function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: string; agentName: string }) {
function RunRow({ run }: { run: AutopilotRun }) {
const wsPaths = useWorkspacePaths();
const cfg = (RUN_STATUS_CONFIG[run.status] ?? RUN_STATUS_CONFIG["issue_created"])!;
const StatusIcon = cfg.icon;
// For runs with a task_id (run_only mode), build a minimal AgentTask so
// TranscriptButton can lazy-load the execution transcript.
const syntheticTask: AgentTask | null = run.task_id
? {
id: run.task_id,
agent_id: agentId,
runtime_id: "",
issue_id: "",
status:
run.status === "running" ? "running" :
run.status === "completed" ? "completed" :
run.status === "failed" ? "failed" :
"queued",
priority: 0,
dispatched_at: null,
started_at: run.triggered_at || null,
completed_at: run.completed_at || null,
result: null,
error: run.failure_reason || null,
created_at: run.created_at,
}
: null;
const content = (
<>
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color, cfg.spin && "animate-spin")} />
@@ -108,14 +83,6 @@ function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: strin
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatDate(run.triggered_at || run.created_at)}
</span>
{syntheticTask && !run.issue_id && (
<TranscriptButton
task={syntheticTask}
agentName={agentName}
isLive={run.status === "running"}
title="View execution log"
/>
)}
</>
);
@@ -471,7 +438,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
) : (
<div className="rounded-md border overflow-hidden">
{runs.map((run) => (
<RunRow key={run.id} run={run} agentId={autopilot.assignee_id} agentName={getActorName("agent", autopilot.assignee_id)} />
<RunRow key={run.id} run={run} />
))}
</div>
)}

View File

@@ -1,165 +0,0 @@
"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,7 +32,6 @@ import {
BookCheck,
ListChecks,
ArrowLeft,
Settings,
} from "lucide-react";
import type { InboxItem } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
@@ -54,7 +53,6 @@ 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();
@@ -202,17 +200,7 @@ export function InboxPage() {
</span>
)}
</div>
<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>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
@@ -244,7 +232,6 @@ export function InboxPage() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</PageHeader>
);

View File

@@ -66,50 +66,6 @@ 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'
@@ -206,9 +162,6 @@ 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" {
@@ -227,11 +180,6 @@ 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",
@@ -289,14 +237,6 @@ 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,
@@ -368,17 +308,10 @@ 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,8 +486,6 @@ 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

@@ -32,22 +32,21 @@ func BuildPrompt(task Task) string {
// buildQuickCreatePrompt constructs a prompt for quick-create tasks. The
// user typed a single natural-language sentence in the create-issue modal;
// the agent's job is to translate it into one `multica issue create` CLI
// invocation, using its judgment to decide whether fetching referenced URLs
// would produce a better issue. No issue exists yet, so the agent must NOT
// call `multica issue get` or attempt to comment — there's nothing to read
// or reply to.
// the agent's only job is to translate it into one `multica issue create`
// CLI invocation. No issue exists yet, so the agent must NOT call
// `multica issue get` or attempt to comment — there's nothing to read or
// reply to.
func buildQuickCreatePrompt(task Task) string {
var b strings.Builder
b.WriteString("You are running as a quick-create assistant for a Multica workspace.\n\n")
b.WriteString("A user pressed the quick-create shortcut and typed a one-line description. There is NO existing issue. Your job is to create a well-formed issue from the user's input with a single `multica issue create` command.\n\n")
b.WriteString("A user pressed the quick-create shortcut and typed a one-line description. There is NO existing issue. Your only job is to translate the description into a single `multica issue create` command and run it.\n\n")
fmt.Fprintf(&b, "User input:\n> %s\n\n", task.QuickCreatePrompt)
b.WriteString("Field rules:\n")
b.WriteString("- title: required. A concise but semantically rich summary that lets a reader understand what the issue is about at a glance. If the user input references external resources (PRs, issues, URLs, etc.), use your judgment to decide whether fetching the resource would produce a meaningfully better title — if so, fetch it and incorporate the relevant context. For example, \"review PR #123\" is much less useful than \"Review PR #123: Refactor auth module to OAuth2\". Strip filler words but preserve key semantic information.\n")
b.WriteString("- description: always provide a rich, self-contained description. The created issue will be picked up and executed by an agent — the more accurate context the description contains, the better the agent will understand and execute the task. Use your judgment to gather context: if the user input contains URLs or references, fetch them and summarize the relevant parts. Spell out what needs to be done, what the background is, and any constraints or details that would help the executing agent avoid misunderstandings. Never echo the title here.\n")
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\" → urgent. If unspecified, omit.\n")
b.WriteString("- title: required. A short, imperative summary extracted from the user input (e.g. \"fix inbox loading\"). Strip filler words.\n")
b.WriteString("- description: optional. Include only if the user supplied detail beyond the title; otherwise omit. Never echo the title here.\n")
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\"/\"紧急\" → urgent; \"低优先级\" → low. If unspecified, omit.\n")
b.WriteString("- assignee:\n")
b.WriteString(" - When the user names someone (\"assign to X\" / \"@X\"), call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `Unrecognized assignee: X`.\n")
b.WriteString(" - When the user names someone (\"分给 X\" / \"assign to X\" / \"@X\"), call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `未识别 assignee: X`.\n")
agentName := ""
if task.Agent != nil {
agentName = task.Agent.Name

View File

@@ -1,133 +0,0 @@
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

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

View File

@@ -1,12 +0,0 @@
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,11 +53,6 @@ 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,16 +321,6 @@ 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

@@ -1,166 +0,0 @@
// 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

@@ -1,23 +0,0 @@
-- 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;