From e72f5f08015a73d82171a837f954542fc6c564a1 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:22:17 +0800 Subject: [PATCH] feat(inbox): add priority/due_date notifications, structured details, and hover card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing notifications for priority_changed and due_date_changed events - Publish priority_changed and due_date_changed flags from UpdateIssue handler - Add details JSONB column to inbox_item (migration 019) for structured change data - Store from/to values in details for status, priority, assignee, and due_date changes - Notification titles now use plain issue title; details carry structured context - Add human-readable label maps (statusLabels, priorityLabels) in notification listeners - Update inbox handler responses to include details field - Frontend: InboxDetailLabel renders rich subtitles per notification type - Status: "Set status to ● In Progress" with StatusIcon - Priority: "Set priority to ◆ High" with PriorityIcon - Assigned: "Assigned to Bob" with resolved actor name - Due date: "Set due date to Apr 20" - Comment: truncated comment body preview - Frontend: HoverCard on inbox items shows issue title + description context - Add due_date_changed to InboxItemType and typeLabels - Add tests for priority_changed and due_date_changed notifications Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/(dashboard)/inbox/page.tsx | 192 ++++++++++++++---- apps/web/shared/types/inbox.ts | 2 + server/cmd/server/notification_listeners.go | 132 ++++++++++-- .../cmd/server/notification_listeners_test.go | 132 ++++++++++++ server/internal/handler/inbox.go | 34 ++-- server/internal/handler/issue.go | 8 + server/migrations/019_inbox_details.down.sql | 1 + server/migrations/019_inbox_details.up.sql | 1 + server/pkg/db/generated/inbox.sql.go | 22 +- server/pkg/db/generated/models.go | 1 + server/pkg/db/queries/inbox.sql | 4 +- 11 files changed, 452 insertions(+), 77 deletions(-) create mode 100644 server/migrations/019_inbox_details.down.sql create mode 100644 server/migrations/019_inbox_details.up.sql diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index 809303c3..8c474198 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -1,12 +1,17 @@ "use client"; -import { useState, useMemo } from "react"; +import { useMemo } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; import { useDefaultLayout } from "react-resizable-panels"; import { useInboxStore } from "@/features/inbox"; -import { IssueDetail, StatusIcon } from "@/features/issues/components"; +import { useIssueStore } from "@/features/issues"; +import { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components"; +import { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config"; +import { useActorName } from "@/features/workspace"; import { ActorAvatar } from "@/components/common/actor-avatar"; import { toast } from "sonner"; import { + ArrowRight, MoreHorizontal, Inbox, CheckCheck, @@ -14,7 +19,7 @@ import { BookCheck, ListChecks, } from "lucide-react"; -import type { InboxItem, InboxItemType, InboxSeverity } from "@/shared/types"; +import type { InboxItem, InboxItemType, InboxSeverity, IssueStatus, IssuePriority } from "@/shared/types"; import { Button } from "@/components/ui/button"; import { ResizablePanelGroup, @@ -29,6 +34,11 @@ import { DropdownMenuItem, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; +import { + HoverCard, + HoverCardTrigger, + HoverCardContent, +} from "@/components/ui/hover-card"; import { api } from "@/shared/api"; // --------------------------------------------------------------------------- @@ -47,6 +57,7 @@ const typeLabels: Record = { assignee_changed: "Assignee changed", status_changed: "Status changed", priority_changed: "Priority changed", + due_date_changed: "Due date changed", new_comment: "New comment", mentioned: "Mentioned", review_requested: "Review requested", @@ -67,6 +78,95 @@ function timeAgo(dateStr: string): string { return `${days}d`; } +function shortDate(dateStr: string): string { + if (!dateStr) return ""; + return new Date(dateStr).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +// --------------------------------------------------------------------------- +// InboxHoverContent — shows issue context on hover +// --------------------------------------------------------------------------- + +function InboxHoverContent({ item }: { item: InboxItem }) { + const issues = useIssueStore((s) => s.issues); + const issue = item.issue_id ? issues.find((i) => i.id === item.issue_id) : null; + + if (!issue) return null; + + return ( +
+
+ +

{issue.title}

+
+ {issue.description && ( +

{issue.description}

+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// InboxDetailLabel — renders rich subtitle per notification type +// --------------------------------------------------------------------------- + +function InboxDetailLabel({ item }: { item: InboxItem }) { + const { getActorName } = useActorName(); + const details = item.details ?? {}; + + switch (item.type) { + case "status_changed": { + if (!details.to) return {typeLabels[item.type]}; + const label = STATUS_CONFIG[details.to as IssueStatus]?.label ?? details.to; + return ( + + Set status to + + {label} + + ); + } + case "priority_changed": { + if (!details.to) return {typeLabels[item.type]}; + const label = PRIORITY_CONFIG[details.to as IssuePriority]?.label ?? details.to; + return ( + + Set priority to + + {label} + + ); + } + case "issue_assigned": { + if (details.new_assignee_id) { + return Assigned to {getActorName(details.new_assignee_type ?? "member", details.new_assignee_id)}; + } + return {typeLabels[item.type]}; + } + case "unassigned": + return Removed assignee; + case "assignee_changed": { + if (details.new_assignee_id) { + return Assigned to {getActorName(details.new_assignee_type ?? "member", details.new_assignee_id)}; + } + return {typeLabels[item.type]}; + } + case "due_date_changed": { + if (details.to) return Set due date to {shortDate(details.to)}; + return Removed due date; + } + case "new_comment": { + if (item.body) return {item.body}; + return {typeLabels[item.type]}; + } + default: + return {typeLabels[item.type] ?? item.type}; + } +} + // --------------------------------------------------------------------------- // InboxListItem // --------------------------------------------------------------------------- @@ -81,43 +181,52 @@ function InboxListItem({ onClick: () => void; }) { return ( - + + + + + ); } @@ -126,7 +235,16 @@ function InboxListItem({ // --------------------------------------------------------------------------- export default function InboxPage() { - const [selectedId, setSelectedId] = useState(""); + const searchParams = useSearchParams(); + const router = useRouter(); + const selectedId = searchParams.get("id") ?? ""; + const setSelectedId = (id: string) => { + if (id) { + router.replace(`/inbox?id=${id}`, { scroll: false }); + } else { + router.replace("/inbox", { scroll: false }); + } + }; const storeItems = useInboxStore((s) => s.items); const loading = useInboxStore((s) => s.loading); diff --git a/apps/web/shared/types/inbox.ts b/apps/web/shared/types/inbox.ts index 4f5b0864..b9a901fa 100644 --- a/apps/web/shared/types/inbox.ts +++ b/apps/web/shared/types/inbox.ts @@ -8,6 +8,7 @@ export type InboxItemType = | "assignee_changed" | "status_changed" | "priority_changed" + | "due_date_changed" | "new_comment" | "mentioned" | "review_requested" @@ -32,4 +33,5 @@ export interface InboxItem { read: boolean; archived: boolean; created_at: string; + details: Record | null; } diff --git a/server/cmd/server/notification_listeners.go b/server/cmd/server/notification_listeners.go index a99fe3bc..f1e1503b 100644 --- a/server/cmd/server/notification_listeners.go +++ b/server/cmd/server/notification_listeners.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "log/slog" "regexp" @@ -21,6 +22,42 @@ type mention struct { // mentionRe matches [@Label](mention://type/id) in markdown. var mentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent)/([0-9a-fA-F-]+)\)`) +// statusLabels maps DB status values to human-readable labels for notifications. +var statusLabels = map[string]string{ + "backlog": "Backlog", + "todo": "Todo", + "in_progress": "In Progress", + "in_review": "In Review", + "done": "Done", + "blocked": "Blocked", + "cancelled": "Cancelled", +} + +// priorityLabels maps DB priority values to human-readable labels for notifications. +var priorityLabels = map[string]string{ + "urgent": "Urgent", + "high": "High", + "medium": "Medium", + "low": "Low", + "none": "No priority", +} + +func statusLabel(s string) string { + if l, ok := statusLabels[s]; ok { + return l + } + return s +} + +func priorityLabel(p string) string { + if l, ok := priorityLabels[p]; ok { + return l + } + return p +} + +var emptyDetails = []byte("{}") + // parseMentions extracts mentions from markdown content. func parseMentions(content string) []mention { matches := mentionRe.FindAllStringSubmatch(content, -1) @@ -53,6 +90,7 @@ func notifySubscribers( severity string, title string, body string, + details []byte, ) { subs, err := queries.ListIssueSubscribers(ctx, parseUUID(issueID)) if err != nil { @@ -90,6 +128,7 @@ func notifySubscribers( Body: util.StrToText(body), ActorType: util.StrToText(e.ActorType), ActorID: parseUUID(e.ActorID), + Details: details, }) if err != nil { slog.Error("subscriber notification creation failed", @@ -125,6 +164,7 @@ func notifyDirect( severity string, title string, body string, + details []byte, ) { // Skip if recipient is the actor if recipientID == e.ActorID { @@ -142,6 +182,7 @@ func notifyDirect( Body: util.StrToText(body), ActorType: util.StrToText(e.ActorType), ActorID: parseUUID(e.ActorID), + Details: details, }) if err != nil { slog.Error("direct notification creation failed", @@ -172,6 +213,7 @@ func notifyMentionedMembers( issueStatus string, title string, skip map[string]bool, + details []byte, ) { for _, m := range mentions { if m.Type != "member" { @@ -190,6 +232,7 @@ func notifyMentionedMembers( Title: title, ActorType: util.StrToText(e.ActorType), ActorID: parseUUID(e.ActorID), + Details: details, }) if err != nil { slog.Error("mention inbox creation failed", "mentioned_id", m.ID, "error", err) @@ -238,8 +281,9 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) { *issue.AssigneeType, *issue.AssigneeID, issue.WorkspaceID, e, issue.ID, issue.Status, "issue_assigned", "action_required", - "New issue assigned: "+issue.Title, + issue.Title, "", + emptyDetails, ) } @@ -247,11 +291,11 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) { if issue.Description != nil && *issue.Description != "" { mentions := parseMentions(*issue.Description) notifyMentionedMembers(bus, queries, e, mentions, issue.ID, issue.Title, issue.Status, - "Mentioned in: "+issue.Title, skip) + issue.Title, skip, emptyDetails) } }) - // issue:updated — handle assignee changes and status changes + // issue:updated — handle assignee changes, status changes, priority, due date bus.Subscribe(protocol.EventIssueUpdated, func(e events.Event) { payload, ok := e.Payload.(map[string]any) if !ok { @@ -269,14 +313,31 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) { prevDescription, _ := payload["prev_description"].(*string) if assigneeChanged { + // Build structured details for assignee change + detailsMap := map[string]any{} + if prevAssigneeType != nil { + detailsMap["prev_assignee_type"] = *prevAssigneeType + } + if prevAssigneeID != nil { + detailsMap["prev_assignee_id"] = *prevAssigneeID + } + if issue.AssigneeType != nil { + detailsMap["new_assignee_type"] = *issue.AssigneeType + } + if issue.AssigneeID != nil { + detailsMap["new_assignee_id"] = *issue.AssigneeID + } + assigneeDetails, _ := json.Marshal(detailsMap) + // Direct: notify new assignee about assignment if issue.AssigneeType != nil && issue.AssigneeID != nil { notifyDirect(ctx, queries, bus, *issue.AssigneeType, *issue.AssigneeID, e.WorkspaceID, e, issue.ID, issue.Status, "issue_assigned", "action_required", - "Assigned to you: "+issue.Title, + issue.Title, "", + assigneeDetails, ) } @@ -286,8 +347,9 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) { "member", *prevAssigneeID, e.WorkspaceID, e, issue.ID, issue.Status, "unassigned", "info", - "Unassigned from: "+issue.Title, + issue.Title, "", + assigneeDetails, ) } @@ -302,14 +364,51 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) { } notifySubscribers(ctx, queries, bus, issue.ID, issue.Status, e.WorkspaceID, e, exclude, "assignee_changed", "info", - "Assignee changed: "+issue.Title, "") + issue.Title, "", + assigneeDetails) } if statusChanged { - // Subscriber: notify all subscribers except actor + prevStatus, _ := payload["prev_status"].(string) + statusDetails, _ := json.Marshal(map[string]string{ + "from": prevStatus, + "to": issue.Status, + }) notifySubscribers(ctx, queries, bus, issue.ID, issue.Status, e.WorkspaceID, e, nil, "status_changed", "info", - issue.Title+" moved to "+issue.Status, "") + issue.Title, "", + statusDetails) + } + + if priorityChanged, _ := payload["priority_changed"].(bool); priorityChanged { + prevPriority, _ := payload["prev_priority"].(string) + priorityDetails, _ := json.Marshal(map[string]string{ + "from": prevPriority, + "to": issue.Priority, + }) + notifySubscribers(ctx, queries, bus, issue.ID, issue.Status, e.WorkspaceID, e, + nil, "priority_changed", "info", + issue.Title, "", + priorityDetails) + } + + if dueDateChanged, _ := payload["due_date_changed"].(bool); dueDateChanged { + prevDueDateStr := "" + if prevDueDate, ok := payload["prev_due_date"].(*string); ok && prevDueDate != nil { + prevDueDateStr = *prevDueDate + } + newDueDateStr := "" + if issue.DueDate != nil { + newDueDateStr = *issue.DueDate + } + dueDateDetails, _ := json.Marshal(map[string]string{ + "from": prevDueDateStr, + "to": newDueDateStr, + }) + notifySubscribers(ctx, queries, bus, issue.ID, issue.Status, e.WorkspaceID, e, + nil, "due_date_changed", "info", + issue.Title, "", + dueDateDetails) } // Notify NEW @mentions in description @@ -330,7 +429,7 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) { } skip := map[string]bool{e.ActorID: true} notifyMentionedMembers(bus, queries, e, added, issue.ID, issue.Title, issue.Status, - "Mentioned in: "+issue.Title, skip) + issue.Title, skip, emptyDetails) } } }) @@ -362,17 +461,15 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) { notifySubscribers(ctx, queries, bus, issueID, issueStatus, e.WorkspaceID, e, nil, "new_comment", "info", - "New comment on: "+issueTitle, commentContent) + issueTitle, commentContent, + emptyDetails) // Notify @mentions in comment content. - // TODO: when @mention feature is enabled, pass already-notified subscriber IDs - // into the skip set to avoid duplicate notifications for users who are both - // subscribers and @mentioned. mentions := parseMentions(commentContent) if len(mentions) > 0 { skip := map[string]bool{e.ActorID: true} notifyMentionedMembers(bus, queries, e, mentions, issueID, issueTitle, issueStatus, - "Mentioned in comment: "+issueTitle, skip) + issueTitle, skip, emptyDetails) } }) @@ -409,7 +506,8 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) { ActorID: agentID, }, exclude, "task_completed", "attention", - "Task completed: "+issue.Title, "") + issue.Title, "", + emptyDetails) }) // task:failed — notify all subscribers except the agent @@ -443,7 +541,8 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) { ActorID: agentID, }, exclude, "task_failed", "action_required", - "Task failed: "+issue.Title, "") + issue.Title, "", + emptyDetails) }) } @@ -465,5 +564,6 @@ func inboxItemToResponse(item db.InboxItem) map[string]any { "created_at": util.TimestampToString(item.CreatedAt), "actor_type": util.TextToPtr(item.ActorType), "actor_id": util.UUIDToPtr(item.ActorID), + "details": json.RawMessage(item.Details), } } diff --git a/server/cmd/server/notification_listeners_test.go b/server/cmd/server/notification_listeners_test.go index aa2a2a4c..79aa3f0a 100644 --- a/server/cmd/server/notification_listeners_test.go +++ b/server/cmd/server/notification_listeners_test.go @@ -260,6 +260,7 @@ func TestNotification_StatusChanged(t *testing.T) { }, "assignee_changed": false, "status_changed": true, + "prev_status": "todo", }, }) @@ -280,6 +281,11 @@ func TestNotification_StatusChanged(t *testing.T) { if sub1Items[0].Severity != "info" { t.Fatalf("expected severity 'info', got %q", sub1Items[0].Severity) } + // Title is now just the issue title; details contain from/to + expectedTitle := "status test issue" + if sub1Items[0].Title != expectedTitle { + t.Fatalf("expected title %q, got %q", expectedTitle, sub1Items[0].Title) + } // sub2 should also get a status_changed notification sub2Items := inboxItemsForRecipient(t, queries, sub2ID) @@ -550,3 +556,129 @@ func TestNotification_TaskFailed(t *testing.T) { t.Fatalf("expected severity 'action_required', got %q", creatorItems[0].Severity) } } + +// TestNotification_PriorityChanged verifies that all subscribers except the actor +// receive a "priority_changed" notification when an issue priority changes. +func TestNotification_PriorityChanged(t *testing.T) { + queries := db.New(testPool) + bus := newNotificationBus(t, queries) + + sub1Email := "notif-sub1-priority@multica.ai" + sub1ID := createTestUser(t, sub1Email) + t.Cleanup(func() { cleanupTestUser(t, sub1Email) }) + + issueID := createTestIssue(t, testWorkspaceID, testUserID) + t.Cleanup(func() { + cleanupInboxForIssue(t, issueID) + cleanupTestIssue(t, issueID) + }) + + addTestSubscriber(t, issueID, "member", testUserID, "creator") + addTestSubscriber(t, issueID, "member", sub1ID, "assignee") + + bus.Publish(events.Event{ + Type: protocol.EventIssueUpdated, + WorkspaceID: testWorkspaceID, + ActorType: "member", + ActorID: testUserID, + Payload: map[string]any{ + "issue": handler.IssueResponse{ + ID: issueID, + WorkspaceID: testWorkspaceID, + Title: "priority test issue", + Status: "todo", + Priority: "high", + CreatorType: "member", + CreatorID: testUserID, + }, + "assignee_changed": false, + "status_changed": false, + "priority_changed": true, + "prev_priority": "medium", + }, + }) + + // Actor should NOT get a notification + actorItems := inboxItemsForRecipient(t, queries, testUserID) + if len(actorItems) != 0 { + t.Fatalf("expected 0 inbox items for actor, got %d", len(actorItems)) + } + + // sub1 should get a priority_changed notification + sub1Items := inboxItemsForRecipient(t, queries, sub1ID) + if len(sub1Items) != 1 { + t.Fatalf("expected 1 inbox item for sub1, got %d", len(sub1Items)) + } + if sub1Items[0].Type != "priority_changed" { + t.Fatalf("expected type 'priority_changed', got %q", sub1Items[0].Type) + } + if sub1Items[0].Severity != "info" { + t.Fatalf("expected severity 'info', got %q", sub1Items[0].Severity) + } + // Title is now just the issue title; details contain from/to + expectedTitle := "priority test issue" + if sub1Items[0].Title != expectedTitle { + t.Fatalf("expected title %q, got %q", expectedTitle, sub1Items[0].Title) + } +} + +// TestNotification_DueDateChanged verifies that all subscribers except the actor +// receive a "due_date_changed" notification when an issue due date changes. +func TestNotification_DueDateChanged(t *testing.T) { + queries := db.New(testPool) + bus := newNotificationBus(t, queries) + + sub1Email := "notif-sub1-duedate@multica.ai" + sub1ID := createTestUser(t, sub1Email) + t.Cleanup(func() { cleanupTestUser(t, sub1Email) }) + + issueID := createTestIssue(t, testWorkspaceID, testUserID) + t.Cleanup(func() { + cleanupInboxForIssue(t, issueID) + cleanupTestIssue(t, issueID) + }) + + addTestSubscriber(t, issueID, "member", testUserID, "creator") + addTestSubscriber(t, issueID, "member", sub1ID, "assignee") + + dueDate := "2026-04-15T00:00:00Z" + bus.Publish(events.Event{ + Type: protocol.EventIssueUpdated, + WorkspaceID: testWorkspaceID, + ActorType: "member", + ActorID: testUserID, + Payload: map[string]any{ + "issue": handler.IssueResponse{ + ID: issueID, + WorkspaceID: testWorkspaceID, + Title: "due date test issue", + Status: "todo", + Priority: "medium", + CreatorType: "member", + CreatorID: testUserID, + DueDate: &dueDate, + }, + "assignee_changed": false, + "status_changed": false, + "due_date_changed": true, + }, + }) + + // Actor should NOT get a notification + actorItems := inboxItemsForRecipient(t, queries, testUserID) + if len(actorItems) != 0 { + t.Fatalf("expected 0 inbox items for actor, got %d", len(actorItems)) + } + + // sub1 should get a due_date_changed notification + sub1Items := inboxItemsForRecipient(t, queries, sub1ID) + if len(sub1Items) != 1 { + t.Fatalf("expected 1 inbox item for sub1, got %d", len(sub1Items)) + } + if sub1Items[0].Type != "due_date_changed" { + t.Fatalf("expected type 'due_date_changed', got %q", sub1Items[0].Type) + } + if sub1Items[0].Severity != "info" { + t.Fatalf("expected severity 'info', got %q", sub1Items[0].Severity) + } +} diff --git a/server/internal/handler/inbox.go b/server/internal/handler/inbox.go index 41e243f9..fbd01423 100644 --- a/server/internal/handler/inbox.go +++ b/server/internal/handler/inbox.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "log/slog" "net/http" "strconv" @@ -14,21 +15,22 @@ import ( ) type InboxItemResponse struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - RecipientType string `json:"recipient_type"` - RecipientID string `json:"recipient_id"` - Type string `json:"type"` - Severity string `json:"severity"` - IssueID *string `json:"issue_id"` - Title string `json:"title"` - Body *string `json:"body"` - Read bool `json:"read"` - Archived bool `json:"archived"` - CreatedAt string `json:"created_at"` - IssueStatus *string `json:"issue_status"` - ActorType *string `json:"actor_type"` - ActorID *string `json:"actor_id"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + RecipientType string `json:"recipient_type"` + RecipientID string `json:"recipient_id"` + Type string `json:"type"` + Severity string `json:"severity"` + IssueID *string `json:"issue_id"` + Title string `json:"title"` + Body *string `json:"body"` + Read bool `json:"read"` + Archived bool `json:"archived"` + CreatedAt string `json:"created_at"` + IssueStatus *string `json:"issue_status"` + ActorType *string `json:"actor_type"` + ActorID *string `json:"actor_id"` + Details json.RawMessage `json:"details"` } func inboxToResponse(i db.InboxItem) InboxItemResponse { @@ -47,6 +49,7 @@ func inboxToResponse(i db.InboxItem) InboxItemResponse { CreatedAt: timestampToString(i.CreatedAt), ActorType: textToPtr(i.ActorType), ActorID: uuidToPtr(i.ActorID), + Details: json.RawMessage(i.Details), } } @@ -67,6 +70,7 @@ func inboxRowToResponse(r db.ListInboxItemsRow) InboxItemResponse { IssueStatus: textToPtr(r.IssueStatus), ActorType: textToPtr(r.ActorType), ActorID: uuidToPtr(r.ActorID), + Details: json.RawMessage(r.Details), } } diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 8528f490..69c76b0c 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -332,16 +332,24 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { assigneeChanged := (req.AssigneeType != nil || req.AssigneeID != nil) && (prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID)) statusChanged := req.Status != nil && prevIssue.Status != issue.Status + priorityChanged := req.Priority != nil && prevIssue.Priority != issue.Priority descriptionChanged := req.Description != nil && textToPtr(prevIssue.Description) != resp.Description + prevDueDate := timestampToPtr(prevIssue.DueDate) + dueDateChanged := prevDueDate != resp.DueDate && (prevDueDate == nil) != (resp.DueDate == nil) || + (prevDueDate != nil && resp.DueDate != nil && *prevDueDate != *resp.DueDate) h.publish(protocol.EventIssueUpdated, workspaceID, "member", userID, map[string]any{ "issue": resp, "assignee_changed": assigneeChanged, "status_changed": statusChanged, + "priority_changed": priorityChanged, + "due_date_changed": dueDateChanged, "description_changed": descriptionChanged, "prev_assignee_type": textToPtr(prevIssue.AssigneeType), "prev_assignee_id": uuidToPtr(prevIssue.AssigneeID), "prev_status": prevIssue.Status, + "prev_priority": prevIssue.Priority, + "prev_due_date": prevDueDate, "prev_description": textToPtr(prevIssue.Description), "creator_type": prevIssue.CreatorType, "creator_id": uuidToString(prevIssue.CreatorID), diff --git a/server/migrations/019_inbox_details.down.sql b/server/migrations/019_inbox_details.down.sql new file mode 100644 index 00000000..20e65efc --- /dev/null +++ b/server/migrations/019_inbox_details.down.sql @@ -0,0 +1 @@ +ALTER TABLE inbox_item DROP COLUMN IF EXISTS details; diff --git a/server/migrations/019_inbox_details.up.sql b/server/migrations/019_inbox_details.up.sql new file mode 100644 index 00000000..3ba6e69c --- /dev/null +++ b/server/migrations/019_inbox_details.up.sql @@ -0,0 +1 @@ +ALTER TABLE inbox_item ADD COLUMN IF NOT EXISTS details JSONB DEFAULT '{}'; diff --git a/server/pkg/db/generated/inbox.sql.go b/server/pkg/db/generated/inbox.sql.go index 7c8ba2d9..9dc7e3b5 100644 --- a/server/pkg/db/generated/inbox.sql.go +++ b/server/pkg/db/generated/inbox.sql.go @@ -54,7 +54,7 @@ func (q *Queries) ArchiveCompletedInbox(ctx context.Context, recipientID pgtype. const archiveInboxItem = `-- name: ArchiveInboxItem :one UPDATE inbox_item SET archived = true WHERE id = $1 -RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id +RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id, details ` func (q *Queries) ArchiveInboxItem(ctx context.Context, id pgtype.UUID) (InboxItem, error) { @@ -75,6 +75,7 @@ func (q *Queries) ArchiveInboxItem(ctx context.Context, id pgtype.UUID) (InboxIt &i.CreatedAt, &i.ActorType, &i.ActorID, + &i.Details, ) return i, err } @@ -100,9 +101,9 @@ const createInboxItem = `-- name: CreateInboxItem :one INSERT INTO inbox_item ( workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, - actor_type, actor_id -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) -RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id + actor_type, actor_id, details +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) +RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id, details ` type CreateInboxItemParams struct { @@ -116,6 +117,7 @@ type CreateInboxItemParams struct { Body pgtype.Text `json:"body"` ActorType pgtype.Text `json:"actor_type"` ActorID pgtype.UUID `json:"actor_id"` + Details []byte `json:"details"` } func (q *Queries) CreateInboxItem(ctx context.Context, arg CreateInboxItemParams) (InboxItem, error) { @@ -130,6 +132,7 @@ func (q *Queries) CreateInboxItem(ctx context.Context, arg CreateInboxItemParams arg.Body, arg.ActorType, arg.ActorID, + arg.Details, ) var i InboxItem err := row.Scan( @@ -147,12 +150,13 @@ func (q *Queries) CreateInboxItem(ctx context.Context, arg CreateInboxItemParams &i.CreatedAt, &i.ActorType, &i.ActorID, + &i.Details, ) return i, err } const getInboxItem = `-- name: GetInboxItem :one -SELECT id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id FROM inbox_item +SELECT id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id, details FROM inbox_item WHERE id = $1 ` @@ -174,12 +178,13 @@ func (q *Queries) GetInboxItem(ctx context.Context, id pgtype.UUID) (InboxItem, &i.CreatedAt, &i.ActorType, &i.ActorID, + &i.Details, ) return i, err } const listInboxItems = `-- name: ListInboxItems :many -SELECT i.id, i.workspace_id, i.recipient_type, i.recipient_id, i.type, i.severity, i.issue_id, i.title, i.body, i.read, i.archived, i.created_at, i.actor_type, i.actor_id, +SELECT i.id, i.workspace_id, i.recipient_type, i.recipient_id, i.type, i.severity, i.issue_id, i.title, i.body, i.read, i.archived, i.created_at, i.actor_type, i.actor_id, i.details, iss.status as issue_status FROM inbox_item i LEFT JOIN issue iss ON iss.id = i.issue_id @@ -210,6 +215,7 @@ type ListInboxItemsRow struct { CreatedAt pgtype.Timestamptz `json:"created_at"` ActorType pgtype.Text `json:"actor_type"` ActorID pgtype.UUID `json:"actor_id"` + Details []byte `json:"details"` IssueStatus pgtype.Text `json:"issue_status"` } @@ -242,6 +248,7 @@ func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams) &i.CreatedAt, &i.ActorType, &i.ActorID, + &i.Details, &i.IssueStatus, ); err != nil { return nil, err @@ -270,7 +277,7 @@ func (q *Queries) MarkAllInboxRead(ctx context.Context, recipientID pgtype.UUID) const markInboxRead = `-- name: MarkInboxRead :one UPDATE inbox_item SET read = true WHERE id = $1 -RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id +RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id, details ` func (q *Queries) MarkInboxRead(ctx context.Context, id pgtype.UUID) (InboxItem, error) { @@ -291,6 +298,7 @@ func (q *Queries) MarkInboxRead(ctx context.Context, id pgtype.UUID) (InboxItem, &i.CreatedAt, &i.ActorType, &i.ActorID, + &i.Details, ) return i, err } diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 5e5d9758..546a3bad 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -131,6 +131,7 @@ type InboxItem struct { CreatedAt pgtype.Timestamptz `json:"created_at"` ActorType pgtype.Text `json:"actor_type"` ActorID pgtype.UUID `json:"actor_id"` + Details []byte `json:"details"` } type Issue struct { diff --git a/server/pkg/db/queries/inbox.sql b/server/pkg/db/queries/inbox.sql index eed2e2b8..2a144293 100644 --- a/server/pkg/db/queries/inbox.sql +++ b/server/pkg/db/queries/inbox.sql @@ -15,8 +15,8 @@ WHERE id = $1; INSERT INTO inbox_item ( workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, - actor_type, actor_id -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + actor_type, actor_id, details +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *; -- name: MarkInboxRead :one