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:
parent
b2ee151306
commit
e72f5f0801
11 changed files with 452 additions and 77 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
1
server/migrations/019_inbox_details.down.sql
Normal file
1
server/migrations/019_inbox_details.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE inbox_item DROP COLUMN IF EXISTS details;
|
||||
1
server/migrations/019_inbox_details.up.sql
Normal file
1
server/migrations/019_inbox_details.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE inbox_item ADD COLUMN IF NOT EXISTS details JSONB DEFAULT '{}';
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue