feat(inbox): add priority/due_date notifications, structured details, and hover card

- 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) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-29 00:22:17 +08:00
parent b2ee151306
commit e72f5f0801
11 changed files with 452 additions and 77 deletions

View file

@ -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<InboxItemType, string> = {
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 (
<div className="space-y-1.5">
<div className="flex items-start gap-2">
<StatusIcon status={issue.status} className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<p className="text-sm font-medium line-clamp-2">{issue.title}</p>
</div>
{issue.description && (
<p className="line-clamp-2 text-xs text-muted-foreground">{issue.description}</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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 <span>{typeLabels[item.type]}</span>;
const label = STATUS_CONFIG[details.to as IssueStatus]?.label ?? details.to;
return (
<span className="inline-flex items-center gap-1">
Set status to
<StatusIcon status={details.to as IssueStatus} className="h-3 w-3" />
{label}
</span>
);
}
case "priority_changed": {
if (!details.to) return <span>{typeLabels[item.type]}</span>;
const label = PRIORITY_CONFIG[details.to as IssuePriority]?.label ?? details.to;
return (
<span className="inline-flex items-center gap-1">
Set priority to
<PriorityIcon priority={details.to as IssuePriority} className="h-3 w-3" />
{label}
</span>
);
}
case "issue_assigned": {
if (details.new_assignee_id) {
return <span>Assigned to {getActorName(details.new_assignee_type ?? "member", details.new_assignee_id)}</span>;
}
return <span>{typeLabels[item.type]}</span>;
}
case "unassigned":
return <span>Removed assignee</span>;
case "assignee_changed": {
if (details.new_assignee_id) {
return <span>Assigned to {getActorName(details.new_assignee_type ?? "member", details.new_assignee_id)}</span>;
}
return <span>{typeLabels[item.type]}</span>;
}
case "due_date_changed": {
if (details.to) return <span>Set due date to {shortDate(details.to)}</span>;
return <span>Removed due date</span>;
}
case "new_comment": {
if (item.body) return <span className="truncate">{item.body}</span>;
return <span>{typeLabels[item.type]}</span>;
}
default:
return <span>{typeLabels[item.type] ?? item.type}</span>;
}
}
// ---------------------------------------------------------------------------
// InboxListItem
// ---------------------------------------------------------------------------
@ -81,43 +181,52 @@ function InboxListItem({
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
isSelected ? "bg-accent" : "hover:bg-accent/50"
}`}
>
<ActorAvatar
actorType={item.actor_type ?? item.recipient_type}
actorId={item.actor_id ?? item.recipient_id}
size={28}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
{!item.read && (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
<HoverCard>
<HoverCardTrigger
render={
<button
onClick={onClick}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
isSelected ? "bg-accent" : "hover:bg-accent/50"
}`}
/>
}
>
<ActorAvatar
actorType={item.actor_type ?? item.recipient_type}
actorId={item.actor_id ?? item.recipient_id}
size={28}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
{!item.read && (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
)}
<span
className={`truncate text-sm ${!item.read ? "font-medium" : "text-muted-foreground"}`}
>
{item.title}
</span>
</div>
{item.issue_status && (
<StatusIcon status={item.issue_status} className="h-3.5 w-3.5 shrink-0" />
)}
<span
className={`truncate text-sm ${!item.read ? "font-medium" : "text-muted-foreground"}`}
>
{item.title}
</div>
<div className="mt-0.5 flex items-center justify-between gap-2">
<p className={`min-w-0 truncate text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
<InboxDetailLabel item={item} />
</p>
<span className={`shrink-0 text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
{timeAgo(item.created_at)}
</span>
</div>
{item.issue_status && (
<StatusIcon status={item.issue_status} className="h-3.5 w-3.5 shrink-0" />
)}
</div>
<div className="mt-0.5 flex items-center justify-between gap-2">
<p className={`truncate text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
{typeLabels[item.type] ?? item.type}
</p>
<span className={`shrink-0 text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
{timeAgo(item.created_at)}
</span>
</div>
</div>
</button>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-72">
<InboxHoverContent item={item} />
</HoverCardContent>
</HoverCard>
);
}
@ -126,7 +235,16 @@ function InboxListItem({
// ---------------------------------------------------------------------------
export default function InboxPage() {
const [selectedId, setSelectedId] = useState<string>("");
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);

View file

@ -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<string, string> | null;
}

View file

@ -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),
}
}

View file

@ -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)
}
}

View file

@ -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),
}
}

View file

@ -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),

View file

@ -0,0 +1 @@
ALTER TABLE inbox_item DROP COLUMN IF EXISTS details;

View file

@ -0,0 +1 @@
ALTER TABLE inbox_item ADD COLUMN IF NOT EXISTS details JSONB DEFAULT '{}';

View file

@ -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
}

View file

@ -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 {

View file

@ -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