diff --git a/.gitignore b/.gitignore index aed07640..b5e1c38a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist .envrc # build outputs +.turbo .next out build @@ -36,6 +37,9 @@ apps/web/test-results/ # local settings .claude/ +# feature tracking +_features/ + # platform specific *.dmg *.app 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/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index af2b57fd..84b53d68 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -2,7 +2,7 @@ import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "rea import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { Issue, Comment } from "@/shared/types"; +import type { Issue, Comment, TimelineEntry } from "@/shared/types"; // Mock next/navigation vi.mock("next/navigation", () => ({ @@ -108,7 +108,7 @@ vi.mock("@/components/markdown", () => ({ // Mock api const mockGetIssue = vi.hoisted(() => vi.fn()); -const mockListComments = vi.hoisted(() => vi.fn()); +const mockListTimeline = vi.hoisted(() => vi.fn()); const mockCreateComment = vi.hoisted(() => vi.fn()); const mockUpdateComment = vi.hoisted(() => vi.fn()); const mockDeleteComment = vi.hoisted(() => vi.fn()); @@ -118,7 +118,8 @@ const mockUpdateIssue = vi.hoisted(() => vi.fn()); vi.mock("@/shared/api", () => ({ api: { getIssue: (...args: any[]) => mockGetIssue(...args), - listComments: (...args: any[]) => mockListComments(...args), + listTimeline: (...args: any[]) => mockListTimeline(...args), + listComments: vi.fn().mockResolvedValue([]), createComment: (...args: any[]) => mockCreateComment(...args), updateComment: (...args: any[]) => mockUpdateComment(...args), deleteComment: (...args: any[]) => mockDeleteComment(...args), @@ -148,26 +149,28 @@ const mockIssue: Issue = { updated_at: "2026-01-20T00:00:00Z", }; -const mockComments: Comment[] = [ +const mockTimeline: TimelineEntry[] = [ { - id: "comment-1", - issue_id: "issue-1", - content: "Started working on this", type: "comment", - author_type: "member", - author_id: "user-1", + id: "comment-1", + actor_type: "member", + actor_id: "user-1", + content: "Started working on this", + parent_id: null, created_at: "2026-01-16T00:00:00Z", updated_at: "2026-01-16T00:00:00Z", + comment_type: "comment", }, { - id: "comment-2", - issue_id: "issue-1", - content: "I can help with this", type: "comment", - author_type: "agent", - author_id: "agent-1", + id: "comment-2", + actor_type: "agent", + actor_id: "agent-1", + content: "I can help with this", + parent_id: null, created_at: "2026-01-17T00:00:00Z", updated_at: "2026-01-17T00:00:00Z", + comment_type: "comment", }, ]; @@ -193,7 +196,7 @@ describe("IssueDetailPage", () => { it("renders issue details after loading", async () => { mockGetIssue.mockResolvedValueOnce(mockIssue); - mockListComments.mockResolvedValueOnce(mockComments); + mockListTimeline.mockResolvedValueOnce(mockTimeline); await renderPage(); await waitFor(() => { @@ -209,7 +212,7 @@ describe("IssueDetailPage", () => { it("renders issue properties sidebar", async () => { mockGetIssue.mockResolvedValueOnce(mockIssue); - mockListComments.mockResolvedValueOnce(mockComments); + mockListTimeline.mockResolvedValueOnce(mockTimeline); await renderPage(); await waitFor(() => { @@ -222,7 +225,7 @@ describe("IssueDetailPage", () => { it("renders comments", async () => { mockGetIssue.mockResolvedValueOnce(mockIssue); - mockListComments.mockResolvedValueOnce(mockComments); + mockListTimeline.mockResolvedValueOnce(mockTimeline); await renderPage(); await waitFor(() => { @@ -232,12 +235,12 @@ describe("IssueDetailPage", () => { }); expect(screen.getByText("I can help with this")).toBeInTheDocument(); - expect(screen.getByText("Activity")).toBeInTheDocument(); + expect(screen.getAllByText("Activity").length).toBeGreaterThanOrEqual(1); }); it("shows 'Issue not found' for missing issue", async () => { mockGetIssue.mockRejectedValueOnce(new Error("Not found")); - mockListComments.mockRejectedValueOnce(new Error("Not found")); + mockListTimeline.mockRejectedValueOnce(new Error("Not found")); await renderPage("nonexistent-id"); await waitFor(() => { @@ -247,7 +250,7 @@ describe("IssueDetailPage", () => { it("submits a new comment", async () => { mockGetIssue.mockResolvedValueOnce(mockIssue); - mockListComments.mockResolvedValueOnce(mockComments); + mockListTimeline.mockResolvedValueOnce(mockTimeline); const newComment: Comment = { id: "comment-3", @@ -256,6 +259,7 @@ describe("IssueDetailPage", () => { type: "comment", author_type: "member", author_id: "user-1", + parent_id: null, created_at: "2026-01-18T00:00:00Z", updated_at: "2026-01-18T00:00:00Z", }; @@ -277,11 +281,14 @@ describe("IssueDetailPage", () => { fireEvent.change(commentInput, { target: { value: "New test comment" } }); }); - // Wait for button to be enabled after commentEmpty state update - const allButtons = screen.getAllByRole("button"); - const submitBtn = allButtons.find( + // Find the submit button associated with the "Leave a comment..." input. + // Multiple ArrowUp buttons exist (one per ReplyInput), so we find the + // button within the same ReplyInput container as our textarea. + const allArrowUpBtns = screen.getAllByRole("button").filter( (btn) => btn.querySelector(".lucide-arrow-up") !== null, - )!; + ); + // The bottom "Leave a comment..." ReplyInput renders last, so its button is last + const submitBtn = allArrowUpBtns[allArrowUpBtns.length - 1]!; await waitFor(() => { expect(submitBtn).not.toBeDisabled(); }); @@ -301,7 +308,7 @@ describe("IssueDetailPage", () => { it("renders breadcrumb navigation", async () => { mockGetIssue.mockResolvedValueOnce(mockIssue); - mockListComments.mockResolvedValueOnce(mockComments); + mockListTimeline.mockResolvedValueOnce(mockTimeline); await renderPage(); await waitFor(() => { diff --git a/apps/web/components/ui/button.tsx b/apps/web/components/ui/button.tsx index ded01b25..a91c4b5d 100644 --- a/apps/web/components/ui/button.tsx +++ b/apps/web/components/ui/button.tsx @@ -12,11 +12,11 @@ const buttonVariants = cva( variant: { default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 dark:aria-expanded:bg-muted dark:aria-expanded:text-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 dark:aria-expanded:bg-muted dark:aria-expanded:text-foreground", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", link: "text-primary underline-offset-4 hover:underline", diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx new file mode 100644 index 00000000..a8ea065a --- /dev/null +++ b/apps/web/features/issues/components/comment-card.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useState } from "react"; +import { MoreHorizontal } from "lucide-react"; +import { toast } from "sonner"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { ActorAvatar } from "@/components/common/actor-avatar"; +import { Markdown } from "@/components/markdown"; +import { useActorName } from "@/features/workspace"; +import { timeAgo } from "@/shared/utils"; +import { ReplyInput } from "./reply-input"; +import type { TimelineEntry } from "@/shared/types"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface CommentCardProps { + entry: TimelineEntry; + allReplies: Map; + currentUserId?: string; + onReply: (parentId: string, content: string) => Promise; + onEdit: (commentId: string, content: string) => Promise; + onDelete: (commentId: string) => void; +} + +// --------------------------------------------------------------------------- +// Single comment row (used for both parent and replies within the same Card) +// --------------------------------------------------------------------------- + +function CommentRow({ + entry, + currentUserId, + onEdit, + onDelete, +}: { + entry: TimelineEntry; + currentUserId?: string; + onEdit: (commentId: string, content: string) => Promise; + onDelete: (commentId: string) => void; +}) { + const { getActorName } = useActorName(); + const [editing, setEditing] = useState(false); + const [editContent, setEditContent] = useState(""); + + const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId; + const isTemp = entry.id.startsWith("temp-"); + + const startEdit = () => { + setEditContent(entry.content ?? ""); + setEditing(true); + }; + + const cancelEdit = () => { + setEditing(false); + setEditContent(""); + }; + + const saveEdit = async () => { + const trimmed = editContent.trim(); + if (!trimmed) return; + try { + await onEdit(entry.id, trimmed); + setEditing(false); + setEditContent(""); + } catch { + toast.error("Failed to update comment"); + } + }; + + return ( +
+
+ + + {getActorName(entry.actor_type, entry.actor_id)} + + + + {timeAgo(entry.created_at)} + + } + /> + + {new Date(entry.created_at).toLocaleString()} + + + + {!isTemp && isOwn && ( + + + + + } + /> + + Edit + + onDelete(entry.id)} variant="destructive"> + Delete + + + + )} +
+ + {editing ? ( +
{ e.preventDefault(); saveEdit(); }} + className="mt-2 pl-8" + > + setEditContent(e.target.value)} + aria-label="Edit comment" + className="w-full text-sm bg-transparent border-b border-border outline-none py-1" + onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }} + /> +
+ + +
+
+ ) : ( +
+ {entry.content ?? ""} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// CommentCard — One Card per thread (parent + all replies flat inside) +// --------------------------------------------------------------------------- + +function CommentCard({ + entry, + allReplies, + currentUserId, + onReply, + onEdit, + onDelete, +}: CommentCardProps) { + // Collect all nested replies recursively into a flat list + const allNestedReplies: TimelineEntry[] = []; + const collectReplies = (parentId: string) => { + const children = allReplies.get(parentId) ?? []; + for (const child of children) { + allNestedReplies.push(child); + collectReplies(child.id); + } + }; + collectReplies(entry.id); + + return ( + + {/* Parent comment */} +
+ +
+ + {/* Replies — flat, separated by border */} + {allNestedReplies.map((reply) => ( +
+ +
+ ))} + + {/* Reply input — always visible at bottom */} +
+ onReply(entry.id, content)} + /> +
+
+ ); +} + +export { CommentCard, type CommentCardProps }; diff --git a/apps/web/features/issues/components/comment-input.tsx b/apps/web/features/issues/components/comment-input.tsx new file mode 100644 index 00000000..2efdb4fb --- /dev/null +++ b/apps/web/features/issues/components/comment-input.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useRef, useState } from "react"; +import { ArrowUp } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; + +interface CommentInputProps { + onSubmit: (content: string) => Promise; +} + +function CommentInput({ onSubmit }: CommentInputProps) { + const editorRef = useRef(null); + const [isEmpty, setIsEmpty] = useState(true); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async () => { + const content = editorRef.current?.getMarkdown()?.trim(); + if (!content || submitting) return; + setSubmitting(true); + try { + await onSubmit(content); + editorRef.current?.clearContent(); + setIsEmpty(true); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ setIsEmpty(!md.trim())} + onSubmit={handleSubmit} + debounceMs={100} + /> +
+
+ +
+
+ ); +} + +export { CommentInput }; diff --git a/apps/web/features/issues/components/index.ts b/apps/web/features/issues/components/index.ts index 5dec79a1..c95fe21e 100644 --- a/apps/web/features/issues/components/index.ts +++ b/apps/web/features/issues/components/index.ts @@ -3,3 +3,6 @@ export { PriorityIcon } from "./priority-icon"; export { StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers"; export { IssueDetail } from "./issue-detail"; export { IssuesPage } from "./issues-page"; +export { CommentCard } from "./comment-card"; +export { CommentInput } from "./comment-input"; +export { ReplyInput } from "./reply-input"; diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 418c1a17..bb2120cf 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -1,11 +1,10 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { - ArrowUp, Bot, Calendar, ChevronLeft, @@ -13,7 +12,6 @@ import { Link2, MoreHorizontal, PanelRight, - Pencil, Trash2, UserMinus, Users, @@ -47,8 +45,7 @@ import { } from "@/components/ui/dropdown-menu"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; import { Input } from "@/components/ui/input"; -import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; -import { Markdown } from "@/components/markdown"; +import { RichTextEditor } from "@/components/common/rich-text-editor"; import { Tooltip, TooltipTrigger, @@ -59,30 +56,18 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"; import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"; import { ActorAvatar } from "@/components/common/actor-avatar"; -import type { Issue, Comment, IssueSubscriber, UpdateIssueRequest, IssueStatus, IssuePriority } from "@/shared/types"; +import type { Issue, Comment, IssueSubscriber, UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types"; import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config"; import { StatusIcon, PriorityIcon, DueDatePicker } from "@/features/issues/components"; +import { CommentCard } from "./comment-card"; +import { CommentInput } from "./comment-input"; import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; import { useWSEvent } from "@/features/realtime"; import { useIssueStore } from "@/features/issues"; -import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload, SubscriberAddedPayload, SubscriberRemovedPayload } from "@/shared/types"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function timeAgo(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime(); - const minutes = Math.floor(diff / 60000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; -} +import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload, SubscriberAddedPayload, SubscriberRemovedPayload, ActivityCreatedPayload } from "@/shared/types"; +import { timeAgo } from "@/shared/utils"; function shortDate(date: string | null): string { if (!date) return "—"; @@ -92,6 +77,66 @@ function shortDate(date: string | null): string { }); } +function statusLabel(status: string): string { + return STATUS_CONFIG[status as IssueStatus]?.label ?? status; +} + +function priorityLabel(priority: string): string { + return PRIORITY_CONFIG[priority as IssuePriority]?.label ?? priority; +} + +function formatActivity( + entry: TimelineEntry, + resolveActorName?: (type: string, id: string) => string, +): string { + const details = (entry.details ?? {}) as Record; + switch (entry.action) { + case "created": + return "created this issue"; + case "status_changed": + return `changed status from ${statusLabel(details.from ?? "?")} to ${statusLabel(details.to ?? "?")}`; + case "priority_changed": + return `changed priority from ${priorityLabel(details.from ?? "?")} to ${priorityLabel(details.to ?? "?")}`; + case "assignee_changed": { + const isSelfAssign = details.to_type === entry.actor_type && details.to_id === entry.actor_id; + if (isSelfAssign) return "self-assigned this issue"; + const toName = details.to_id && details.to_type && resolveActorName + ? resolveActorName(details.to_type, details.to_id) + : null; + if (toName) return `assigned to ${toName}`; + if (details.from_id && !details.to_id) return "removed assignee"; + return "changed assignee"; + } + case "due_date_changed": { + if (!details.to) return "removed due date"; + const formatted = new Date(details.to).toLocaleDateString("en-US", { month: "short", day: "numeric" }); + return `set due date to ${formatted}`; + } + case "description_updated": + return "updated the description"; + case "task_completed": + return "completed the task"; + case "task_failed": + return "task failed"; + default: + return entry.action ?? ""; + } +} + +function commentToTimelineEntry(c: Comment): TimelineEntry { + return { + type: "comment", + id: c.id, + actor_type: c.author_type, + actor_id: c.author_id, + content: c.content, + parent_id: c.parent_id, + created_at: c.created_at, + updated_at: c.updated_at, + comment_type: c.type, + }; +} + // --------------------------------------------------------------------------- // Property row // --------------------------------------------------------------------------- @@ -147,15 +192,11 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { const sidebarRef = usePanelRef(); const [sidebarOpen, setSidebarOpen] = useState(true); const [issue, setIssue] = useState(null); - const [comments, setComments] = useState([]); + const [timeline, setTimeline] = useState([]); const [subscribers, setSubscribers] = useState([]); const [loading, setLoading] = useState(true); - const [commentEmpty, setCommentEmpty] = useState(true); - const commentEditorRef = useRef(null); const [submitting, setSubmitting] = useState(false); const [deleting, setDeleting] = useState(false); - const [editingCommentId, setEditingCommentId] = useState(null); - const [editContent, setEditContent] = useState(""); const [editingTitle, setEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(""); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -173,48 +214,91 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { useEffect(() => { setIssue(null); - setComments([]); + setTimeline([]); setSubscribers([]); setLoading(true); - Promise.all([api.getIssue(id), api.listComments(id), api.listIssueSubscribers(id)]) - .then(([iss, cmts, subs]) => { + Promise.all([api.getIssue(id), api.listTimeline(id), api.listIssueSubscribers(id)]) + .then(([iss, entries, subs]) => { setIssue(iss); - setComments(cmts); + setTimeline(entries); setSubscribers(subs); }) .catch(console.error) .finally(() => setLoading(false)); }, [id]); - const handleSubmitComment = async () => { - const content = commentEditorRef.current?.getMarkdown()?.trim(); - if (!content || submitting || !user) return; + const handleSubmitComment = async (content: string) => { + if (!content.trim() || submitting || !user) return; const tempId = "temp-" + Date.now(); - const tempComment: Comment = { - id: tempId, - issue_id: id, - author_type: "member", - author_id: user.id, - content, + const tempEntry: TimelineEntry = { type: "comment", + id: tempId, + actor_type: "member", + actor_id: user.id, + content, + parent_id: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), + comment_type: "comment", }; - setComments((prev) => [...prev, tempComment]); - commentEditorRef.current?.clearContent(); - setCommentEmpty(true); + setTimeline((prev) => [...prev, tempEntry]); setSubmitting(true); try { const comment = await api.createComment(id, content); - setComments((prev) => prev.map((c) => (c.id === tempId ? comment : c))); + setTimeline((prev) => prev.map((e) => (e.id === tempId ? commentToTimelineEntry(comment) : e))); } catch { - setComments((prev) => prev.filter((c) => c.id !== tempId)); + setTimeline((prev) => prev.filter((e) => e.id !== tempId)); toast.error("Failed to send comment"); } finally { setSubmitting(false); } }; + const handleSubmitReply = async (parentId: string, content: string) => { + if (!content.trim() || !user) return; + try { + const comment = await api.createComment(id, content, "comment", parentId); + setTimeline((prev) => { + if (prev.some((e) => e.id === comment.id)) return prev; + return [...prev, commentToTimelineEntry(comment)]; + }); + } catch { + toast.error("Failed to send reply"); + } + }; + + const handleEditComment = async (commentId: string, content: string) => { + try { + const updated = await api.updateComment(commentId, content); + setTimeline((prev) => prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e))); + } catch { + toast.error("Failed to update comment"); + } + }; + + const handleDeleteComment = async (commentId: string) => { + try { + await api.deleteComment(commentId); + setTimeline((prev) => { + const idsToRemove = new Set([commentId]); + // Recursively collect all descendant IDs + let added = true; + while (added) { + added = false; + for (const e of prev) { + if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) { + idsToRemove.add(e.id); + added = true; + } + } + } + return prev.filter((e) => !idsToRemove.has(e.id)); + }); + } catch { + toast.error("Failed to delete comment"); + } + }; + const handleUpdateField = useCallback( (updates: Partial) => { if (!issue) return; @@ -241,31 +325,6 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { } }; - const startEditComment = (c: Comment) => { - setEditingCommentId(c.id); - setEditContent(c.content); - }; - - const handleSaveEditComment = async () => { - if (!editingCommentId || !editContent.trim()) return; - try { - const updated = await api.updateComment(editingCommentId, editContent.trim()); - setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); - setEditingCommentId(null); - } catch { - toast.error("Failed to update comment"); - } - }; - - const handleDeleteComment = async (commentId: string) => { - try { - await api.deleteComment(commentId); - setComments((prev) => prev.filter((c) => c.id !== commentId)); - } catch { - toast.error("Failed to delete comment"); - } - }; - // Subscriber state const isSubscribed = subscribers.some( (s) => s.user_type === "member" && s.user_id === user?.id @@ -302,9 +361,9 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { if (comment.issue_id !== id) return; // Skip own comments — already added locally via API response if (comment.author_type === "member" && comment.author_id === user?.id) return; - setComments((prev) => { - if (prev.some((c) => c.id === comment.id)) return prev; - return [...prev, comment]; + setTimeline((prev) => { + if (prev.some((e) => e.id === comment.id)) return prev; + return [...prev, commentToTimelineEntry(comment)]; }); }, [id, user?.id]), ); @@ -314,7 +373,7 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { useCallback((payload: unknown) => { const { comment } = payload as CommentUpdatedPayload; if (comment.issue_id === id) { - setComments((prev) => prev.map((c) => (c.id === comment.id ? comment : c))); + setTimeline((prev) => prev.map((e) => (e.id === comment.id ? commentToTimelineEntry(comment) : e))); } }, [id]), ); @@ -324,11 +383,38 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { useCallback((payload: unknown) => { const { comment_id, issue_id } = payload as CommentDeletedPayload; if (issue_id === id) { - setComments((prev) => prev.filter((c) => c.id !== comment_id)); + setTimeline((prev) => { + const idsToRemove = new Set([comment_id]); + let added = true; + while (added) { + added = false; + for (const e of prev) { + if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) { + idsToRemove.add(e.id); + added = true; + } + } + } + return prev.filter((e) => !idsToRemove.has(e.id)); + }); } }, [id]), ); + useWSEvent( + "activity:created", + useCallback((payload: unknown) => { + const p = payload as ActivityCreatedPayload; + if (p.issue_id !== id) return; + const entry = p.entry; + if (!entry || !entry.id) return; + setTimeline((prev) => { + if (prev.some((e) => e.id === entry.id)) return prev; + return [...prev, entry]; + }); + }, [id]), + ); + // Real-time subscriber updates useWSEvent( "subscriber:added", @@ -674,7 +760,9 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { {/* Activity / Comments */}
-

Activity

+
+

Activity

+
-
- {comments.map((comment) => { - const isOwn = comment.author_type === "member" && comment.author_id === user?.id; - return ( -
-
- + {(() => { + const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id); + const repliesByParent = new Map(); + for (const e of timeline) { + if (e.type === "comment" && e.parent_id) { + const list = repliesByParent.get(e.parent_id) ?? []; + list.push(e); + repliesByParent.set(e.parent_id, list); + } + } + + // Group consecutive activities together so the connector line works + const groups: { type: "activities" | "comment"; entries: TimelineEntry[] }[] = []; + for (const entry of topLevel) { + if (entry.type === "activity") { + const last = groups[groups.length - 1]; + if (last?.type === "activities") { + last.entries.push(entry); + } else { + groups.push({ type: "activities", entries: [entry] }); + } + } else { + groups.push({ type: "comment", entries: [entry] }); + } + } + + return groups.map((group) => { + if (group.type === "comment") { + const entry = group.entries[0]!; + return ( + - - {getActorName(comment.author_type, comment.author_id)} - - - - {timeAgo(comment.created_at)} - - } - /> - - {new Date(comment.created_at).toLocaleString()} - - - {isOwn && ( -
- - startEditComment(comment)} - className="text-muted-foreground hover:text-foreground" - > - - - } - /> - Edit - - - handleDeleteComment(comment.id)} - className="text-muted-foreground hover:text-destructive" - > - - - } - /> - Delete - -
- )} + ); + } + + return ( +
+ {group.entries.map((entry, idx) => { + const details = (entry.details ?? {}) as Record; + const isStatusChange = entry.action === "status_changed"; + const isPriorityChange = entry.action === "priority_changed"; + const isDueDateChange = entry.action === "due_date_changed"; + const isLast = idx === group.entries.length - 1; + + let leadIcon: React.ReactNode; + if (isStatusChange && details.to) { + leadIcon = ; + } else if (isPriorityChange && details.to) { + leadIcon = ; + } else if (isDueDateChange) { + leadIcon = ; + } else { + leadIcon = ; + } + + return ( +
+
+
{leadIcon}
+ {!isLast &&
} +
+
+ {getActorName(entry.actor_type, entry.actor_id)} + {formatActivity(entry, getActorName)} + + + {timeAgo(entry.created_at)} + + } + /> + + {new Date(entry.created_at).toLocaleString()} + + +
+
+ ); + })}
- {editingCommentId === comment.id ? ( -
{ e.preventDefault(); handleSaveEditComment(); }} className="mt-2 pl-9.5"> - setEditContent(e.target.value)} - aria-label="Edit comment" - className="w-full text-sm bg-transparent border-b outline-none" - onKeyDown={(e) => { if (e.key === "Escape") setEditingCommentId(null); }} - /> -
- ) : ( -
- {comment.content} -
- )} -
- ); - })} + ); + }); + })()}
- {/* Comment input */} -
-
- setCommentEmpty(!md.trim())} - onSubmit={handleSubmitComment} - debounceMs={100} - /> -
-
- - - - - } - /> - Send - -
+ {/* Bottom comment input — no avatar, full width */} +
+
diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx new file mode 100644 index 00000000..dc355509 --- /dev/null +++ b/apps/web/features/issues/components/reply-input.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useRef, useState } from "react"; +import { ArrowUp } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor"; +import { ActorAvatar } from "@/components/common/actor-avatar"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ReplyInputProps { + placeholder?: string; + avatarType: string; + avatarId: string; + onSubmit: (content: string) => Promise; + size?: "sm" | "default"; +} + +// --------------------------------------------------------------------------- +// ReplyInput +// --------------------------------------------------------------------------- + +function ReplyInput({ + placeholder = "Leave a reply...", + avatarType, + avatarId, + onSubmit, + size = "default", +}: ReplyInputProps) { + const editorRef = useRef(null); + const [isEmpty, setIsEmpty] = useState(true); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async () => { + const content = editorRef.current?.getMarkdown()?.trim(); + if (!content || submitting) return; + setSubmitting(true); + try { + await onSubmit(content); + editorRef.current?.clearContent(); + setIsEmpty(true); + } finally { + setSubmitting(false); + } + }; + + const avatarSize = size === "sm" ? 22 : 28; + + return ( +
+ +
+
+ setIsEmpty(!md.trim())} + onSubmit={handleSubmit} + debounceMs={100} + /> +
+
+
+
+ +
+
+
+
+
+ ); +} + +export { ReplyInput, type ReplyInputProps }; diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index b9b34dc4..80b915fa 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -30,6 +30,7 @@ import type { CreatePersonalAccessTokenResponse, RuntimeUsage, RuntimePing, + TimelineEntry, } from "@/shared/types"; import { type Logger, noopLogger } from "@/shared/logger"; @@ -183,13 +184,21 @@ export class ApiClient { return this.fetch(`/api/issues/${issueId}/comments`); } - async createComment(issueId: string, content: string, type?: string): Promise { + async createComment(issueId: string, content: string, type?: string, parentId?: string): Promise { return this.fetch(`/api/issues/${issueId}/comments`, { method: "POST", - body: JSON.stringify({ content, type: type ?? "comment" }), + body: JSON.stringify({ + content, + type: type ?? "comment", + ...(parentId ? { parent_id: parentId } : {}), + }), }); } + async listTimeline(issueId: string): Promise { + return this.fetch(`/api/issues/${issueId}/timeline`); + } + async updateComment(commentId: string, content: string): Promise { return this.fetch(`/api/comments/${commentId}`, { method: "PUT", diff --git a/apps/web/shared/types/activity.ts b/apps/web/shared/types/activity.ts new file mode 100644 index 00000000..335fce8a --- /dev/null +++ b/apps/web/shared/types/activity.ts @@ -0,0 +1,15 @@ +export interface TimelineEntry { + type: "activity" | "comment"; + id: string; + actor_type: string; + actor_id: string; + created_at: string; + // Activity fields + action?: string; + details?: Record; + // Comment fields + content?: string; + parent_id?: string | null; + updated_at?: string; + comment_type?: string; +} diff --git a/apps/web/shared/types/comment.ts b/apps/web/shared/types/comment.ts index 8f5944fd..8ce17d77 100644 --- a/apps/web/shared/types/comment.ts +++ b/apps/web/shared/types/comment.ts @@ -9,6 +9,7 @@ export interface Comment { author_id: string; content: string; type: CommentType; + parent_id: string | null; created_at: string; updated_at: string; } diff --git a/apps/web/shared/types/events.ts b/apps/web/shared/types/events.ts index a0626e2d..954eac26 100644 --- a/apps/web/shared/types/events.ts +++ b/apps/web/shared/types/events.ts @@ -2,6 +2,7 @@ import type { Issue } from "./issue"; import type { Agent } from "./agent"; import type { InboxItem } from "./inbox"; import type { Comment } from "./comment"; +import type { TimelineEntry } from "./activity"; import type { Workspace, MemberWithUser } from "./workspace"; // WebSocket event types (matching Go server protocol/events.go) @@ -35,7 +36,8 @@ export type WSEventType = | "skill:updated" | "skill:deleted" | "subscriber:added" - | "subscriber:removed"; + | "subscriber:removed" + | "activity:created"; export interface WSMessage { type: WSEventType; @@ -139,3 +141,8 @@ export interface SubscriberRemovedPayload { user_type: string; user_id: string; } + +export interface ActivityCreatedPayload { + issue_id: string; + entry: TimelineEntry; +} 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/apps/web/shared/types/index.ts b/apps/web/shared/types/index.ts index 61e97fb4..9ea24796 100644 --- a/apps/web/shared/types/index.ts +++ b/apps/web/shared/types/index.ts @@ -24,6 +24,7 @@ export type { export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace"; export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox"; export type { Comment, CommentType, CommentAuthorType } from "./comment"; +export type { TimelineEntry } from "./activity"; export type { IssueSubscriber } from "./subscriber"; export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon"; export type * from "./events"; diff --git a/apps/web/shared/utils.ts b/apps/web/shared/utils.ts new file mode 100644 index 00000000..096f0f5d --- /dev/null +++ b/apps/web/shared/utils.ts @@ -0,0 +1,10 @@ +export function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} diff --git a/server/cmd/server/activity_listeners.go b/server/cmd/server/activity_listeners.go new file mode 100644 index 00000000..fbf5b359 --- /dev/null +++ b/server/cmd/server/activity_listeners.go @@ -0,0 +1,266 @@ +package main + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/multica-ai/multica/server/internal/events" + "github.com/multica-ai/multica/server/internal/handler" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" + "github.com/multica-ai/multica/server/pkg/protocol" +) + +// registerActivityListeners wires up event bus listeners that record activity +// entries in the activity_log table. Each listener creates one or more activity +// records depending on what changed, then publishes an activity:created event +// for WS broadcasting. +func registerActivityListeners(bus *events.Bus, queries *db.Queries) { + ctx := context.Background() + + // issue:created — record "created" activity + bus.Subscribe(protocol.EventIssueCreated, func(e events.Event) { + payload, ok := e.Payload.(map[string]any) + if !ok { + return + } + issue, ok := payload["issue"].(handler.IssueResponse) + if !ok { + return + } + + activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{ + WorkspaceID: parseUUID(issue.WorkspaceID), + IssueID: parseUUID(issue.ID), + ActorType: util.StrToText(e.ActorType), + ActorID: parseUUID(e.ActorID), + Action: "created", + Details: []byte("{}"), + }) + if err != nil { + slog.Error("activity: failed to record issue created", + "issue_id", issue.ID, "error", err) + return + } + + publishActivityEvent(bus, e, activity) + }) + + // issue:updated — record specific changes as separate activities + bus.Subscribe(protocol.EventIssueUpdated, func(e events.Event) { + payload, ok := e.Payload.(map[string]any) + if !ok { + return + } + issue, ok := payload["issue"].(handler.IssueResponse) + if !ok { + return + } + + statusChanged, _ := payload["status_changed"].(bool) + priorityChanged, _ := payload["priority_changed"].(bool) + assigneeChanged, _ := payload["assignee_changed"].(bool) + descriptionChanged, _ := payload["description_changed"].(bool) + + if statusChanged { + prevStatus, _ := payload["prev_status"].(string) + details, _ := json.Marshal(map[string]string{ + "from": prevStatus, + "to": issue.Status, + }) + activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{ + WorkspaceID: parseUUID(issue.WorkspaceID), + IssueID: parseUUID(issue.ID), + ActorType: util.StrToText(e.ActorType), + ActorID: parseUUID(e.ActorID), + Action: "status_changed", + Details: details, + }) + if err != nil { + slog.Error("activity: failed to record status change", + "issue_id", issue.ID, "error", err) + } else { + publishActivityEvent(bus, e, activity) + } + } + + if priorityChanged { + prevPriority, _ := payload["prev_priority"].(string) + details, _ := json.Marshal(map[string]string{ + "from": prevPriority, + "to": issue.Priority, + }) + activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{ + WorkspaceID: parseUUID(issue.WorkspaceID), + IssueID: parseUUID(issue.ID), + ActorType: util.StrToText(e.ActorType), + ActorID: parseUUID(e.ActorID), + Action: "priority_changed", + Details: details, + }) + if err != nil { + slog.Error("activity: failed to record priority change", + "issue_id", issue.ID, "error", err) + } else { + publishActivityEvent(bus, e, activity) + } + } + + if assigneeChanged { + prevAssigneeType, _ := payload["prev_assignee_type"].(*string) + prevAssigneeID, _ := payload["prev_assignee_id"].(*string) + + detailsMap := map[string]string{} + if prevAssigneeType != nil { + detailsMap["from_type"] = *prevAssigneeType + } + if prevAssigneeID != nil { + detailsMap["from_id"] = *prevAssigneeID + } + if issue.AssigneeType != nil { + detailsMap["to_type"] = *issue.AssigneeType + } + if issue.AssigneeID != nil { + detailsMap["to_id"] = *issue.AssigneeID + } + + details, _ := json.Marshal(detailsMap) + activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{ + WorkspaceID: parseUUID(issue.WorkspaceID), + IssueID: parseUUID(issue.ID), + ActorType: util.StrToText(e.ActorType), + ActorID: parseUUID(e.ActorID), + Action: "assignee_changed", + Details: details, + }) + if err != nil { + slog.Error("activity: failed to record assignee change", + "issue_id", issue.ID, "error", err) + } else { + publishActivityEvent(bus, e, activity) + } + } + + if dueDateChanged, _ := payload["due_date_changed"].(bool); dueDateChanged { + prevDueDate := "" + if v, ok := payload["prev_due_date"].(*string); ok && v != nil { + prevDueDate = *v + } + newDueDate := "" + if issue.DueDate != nil { + newDueDate = *issue.DueDate + } + details, _ := json.Marshal(map[string]string{ + "from": prevDueDate, + "to": newDueDate, + }) + activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{ + WorkspaceID: parseUUID(issue.WorkspaceID), + IssueID: parseUUID(issue.ID), + ActorType: util.StrToText(e.ActorType), + ActorID: parseUUID(e.ActorID), + Action: "due_date_changed", + Details: details, + }) + if err != nil { + slog.Error("activity: failed to record due date change", + "issue_id", issue.ID, "error", err) + } else { + publishActivityEvent(bus, e, activity) + } + } + + if descriptionChanged { + activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{ + WorkspaceID: parseUUID(issue.WorkspaceID), + IssueID: parseUUID(issue.ID), + ActorType: util.StrToText(e.ActorType), + ActorID: parseUUID(e.ActorID), + Action: "description_updated", + Details: []byte("{}"), + }) + if err != nil { + slog.Error("activity: failed to record description change", + "issue_id", issue.ID, "error", err) + } else { + publishActivityEvent(bus, e, activity) + } + } + }) + + // task:completed — record "task_completed" activity + bus.Subscribe(protocol.EventTaskCompleted, func(e events.Event) { + handleTaskActivity(ctx, bus, queries, e, "task_completed") + }) + + // task:failed — record "task_failed" activity + bus.Subscribe(protocol.EventTaskFailed, func(e events.Event) { + handleTaskActivity(ctx, bus, queries, e, "task_failed") + }) +} + +// handleTaskActivity records an activity for task:completed or task:failed events. +func handleTaskActivity(ctx context.Context, bus *events.Bus, queries *db.Queries, e events.Event, action string) { + payload, ok := e.Payload.(map[string]any) + if !ok { + return + } + agentID, _ := payload["agent_id"].(string) + issueID, _ := payload["issue_id"].(string) + if issueID == "" { + return + } + + // Look up issue to get workspace_id + issue, err := queries.GetIssue(ctx, parseUUID(issueID)) + if err != nil { + slog.Error("activity: failed to get issue for task event", + "issue_id", issueID, "action", action, "error", err) + return + } + + activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{ + WorkspaceID: issue.WorkspaceID, + IssueID: parseUUID(issueID), + ActorType: util.StrToText("agent"), + ActorID: parseUUID(agentID), + Action: action, + Details: []byte("{}"), + }) + if err != nil { + slog.Error("activity: failed to record task activity", + "issue_id", issueID, "action", action, "error", err) + return + } + + publishActivityEvent(bus, e, activity) +} + +// publishActivityEvent sends an activity:created event for WS broadcasting. +// Payload matches frontend ActivityCreatedPayload: { issue_id, entry: TimelineEntry } +func publishActivityEvent(bus *events.Bus, original events.Event, activity db.ActivityLog) { + actorType := "" + if activity.ActorType.Valid { + actorType = activity.ActorType.String + } + action := activity.Action + bus.Publish(events.Event{ + Type: protocol.EventActivityCreated, + WorkspaceID: original.WorkspaceID, + ActorType: original.ActorType, + ActorID: original.ActorID, + Payload: map[string]any{ + "issue_id": util.UUIDToString(activity.IssueID), + "entry": map[string]any{ + "type": "activity", + "id": util.UUIDToString(activity.ID), + "actor_type": actorType, + "actor_id": util.UUIDToString(activity.ActorID), + "action": action, + "details": json.RawMessage(activity.Details), + "created_at": util.TimestampToString(activity.CreatedAt), + }, + }, + }) +} diff --git a/server/cmd/server/activity_listeners_test.go b/server/cmd/server/activity_listeners_test.go new file mode 100644 index 00000000..3935793a --- /dev/null +++ b/server/cmd/server/activity_listeners_test.go @@ -0,0 +1,295 @@ +package main + +import ( + "context" + "encoding/json" + "testing" + + "github.com/multica-ai/multica/server/internal/events" + "github.com/multica-ai/multica/server/internal/handler" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" + "github.com/multica-ai/multica/server/pkg/protocol" +) + +// listActivitiesForIssue is a test helper that fetches all activity_log records for an issue. +func listActivitiesForIssue(t *testing.T, queries *db.Queries, issueID string) []db.ActivityLog { + t.Helper() + activities, err := queries.ListActivities(context.Background(), db.ListActivitiesParams{ + IssueID: util.ParseUUID(issueID), + Limit: 100, + Offset: 0, + }) + if err != nil { + t.Fatalf("ListActivities: %v", err) + } + return activities +} + +func cleanupActivities(t *testing.T, issueID string) { + t.Helper() + testPool.Exec(context.Background(), `DELETE FROM activity_log WHERE issue_id = $1`, issueID) +} + +func TestActivityIssueCreated(t *testing.T) { + queries := db.New(testPool) + bus := events.New() + registerActivityListeners(bus, queries) + + issueID := createTestIssue(t, testWorkspaceID, testUserID) + t.Cleanup(func() { + cleanupActivities(t, issueID) + cleanupTestIssue(t, issueID) + }) + + bus.Publish(events.Event{ + Type: protocol.EventIssueCreated, + WorkspaceID: testWorkspaceID, + ActorType: "member", + ActorID: testUserID, + Payload: map[string]any{ + "issue": handler.IssueResponse{ + ID: issueID, + WorkspaceID: testWorkspaceID, + Title: "activity test issue", + Status: "todo", + Priority: "medium", + CreatorType: "member", + CreatorID: testUserID, + }, + }, + }) + + activities := listActivitiesForIssue(t, queries, issueID) + if len(activities) != 1 { + t.Fatalf("expected 1 activity, got %d", len(activities)) + } + if activities[0].Action != "created" { + t.Fatalf("expected action 'created', got %q", activities[0].Action) + } + if util.UUIDToString(activities[0].ActorID) != testUserID { + t.Fatalf("expected actor_id %s, got %s", testUserID, util.UUIDToString(activities[0].ActorID)) + } +} + +func TestActivityIssueUpdated_StatusChanged(t *testing.T) { + queries := db.New(testPool) + bus := events.New() + registerActivityListeners(bus, queries) + + issueID := createTestIssue(t, testWorkspaceID, testUserID) + t.Cleanup(func() { + cleanupActivities(t, issueID) + cleanupTestIssue(t, issueID) + }) + + 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: "activity test issue", + Status: "in_progress", + Priority: "medium", + CreatorType: "member", + CreatorID: testUserID, + }, + "status_changed": true, + "prev_status": "todo", + }, + }) + + activities := listActivitiesForIssue(t, queries, issueID) + if len(activities) != 1 { + t.Fatalf("expected 1 activity, got %d", len(activities)) + } + if activities[0].Action != "status_changed" { + t.Fatalf("expected action 'status_changed', got %q", activities[0].Action) + } + + var details map[string]string + if err := json.Unmarshal(activities[0].Details, &details); err != nil { + t.Fatalf("failed to unmarshal details: %v", err) + } + if details["from"] != "todo" { + t.Fatalf("expected from 'todo', got %q", details["from"]) + } + if details["to"] != "in_progress" { + t.Fatalf("expected to 'in_progress', got %q", details["to"]) + } +} + +func TestActivityIssueUpdated_AssigneeChanged(t *testing.T) { + queries := db.New(testPool) + bus := events.New() + registerActivityListeners(bus, queries) + + assigneeEmail := "activity-assignee-test@multica.ai" + assigneeID := createTestUser(t, assigneeEmail) + t.Cleanup(func() { cleanupTestUser(t, assigneeEmail) }) + + issueID := createTestIssue(t, testWorkspaceID, testUserID) + t.Cleanup(func() { + cleanupActivities(t, issueID) + cleanupTestIssue(t, issueID) + }) + + assigneeType := "member" + 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: "activity test issue", + Status: "todo", + Priority: "medium", + CreatorType: "member", + CreatorID: testUserID, + AssigneeType: &assigneeType, + AssigneeID: &assigneeID, + }, + "assignee_changed": true, + "prev_assignee_type": (*string)(nil), + "prev_assignee_id": (*string)(nil), + }, + }) + + activities := listActivitiesForIssue(t, queries, issueID) + if len(activities) != 1 { + t.Fatalf("expected 1 activity, got %d", len(activities)) + } + if activities[0].Action != "assignee_changed" { + t.Fatalf("expected action 'assignee_changed', got %q", activities[0].Action) + } + + var details map[string]string + if err := json.Unmarshal(activities[0].Details, &details); err != nil { + t.Fatalf("failed to unmarshal details: %v", err) + } + if details["to_type"] != "member" { + t.Fatalf("expected to_type 'member', got %q", details["to_type"]) + } + if details["to_id"] != assigneeID { + t.Fatalf("expected to_id %q, got %q", assigneeID, details["to_id"]) + } +} + +func TestActivityIssueUpdated_NoChangeFlags(t *testing.T) { + queries := db.New(testPool) + bus := events.New() + registerActivityListeners(bus, queries) + + issueID := createTestIssue(t, testWorkspaceID, testUserID) + t.Cleanup(func() { + cleanupActivities(t, issueID) + cleanupTestIssue(t, issueID) + }) + + // Publish issue:updated with no change flags set + 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: "activity test issue", + Status: "todo", + Priority: "medium", + CreatorType: "member", + CreatorID: testUserID, + }, + "assignee_changed": false, + "status_changed": false, + "description_changed": false, + }, + }) + + activities := listActivitiesForIssue(t, queries, issueID) + if len(activities) != 0 { + t.Fatalf("expected 0 activities when no change flags, got %d", len(activities)) + } +} + +func TestActivityTaskCompleted(t *testing.T) { + queries := db.New(testPool) + bus := events.New() + registerActivityListeners(bus, queries) + + issueID := createTestIssue(t, testWorkspaceID, testUserID) + t.Cleanup(func() { + cleanupActivities(t, issueID) + cleanupTestIssue(t, issueID) + }) + + agentID := testUserID // reuse as a stand-in for agent ID + + bus.Publish(events.Event{ + Type: protocol.EventTaskCompleted, + WorkspaceID: testWorkspaceID, + ActorType: "system", + ActorID: "", + Payload: map[string]any{ + "task_id": "00000000-0000-0000-0000-000000000001", + "agent_id": agentID, + "issue_id": issueID, + "status": "completed", + }, + }) + + activities := listActivitiesForIssue(t, queries, issueID) + if len(activities) != 1 { + t.Fatalf("expected 1 activity, got %d", len(activities)) + } + if activities[0].Action != "task_completed" { + t.Fatalf("expected action 'task_completed', got %q", activities[0].Action) + } + if util.UUIDToString(activities[0].ActorID) != agentID { + t.Fatalf("expected actor_id %s, got %s", agentID, util.UUIDToString(activities[0].ActorID)) + } +} + +func TestActivityTaskFailed(t *testing.T) { + queries := db.New(testPool) + bus := events.New() + registerActivityListeners(bus, queries) + + issueID := createTestIssue(t, testWorkspaceID, testUserID) + t.Cleanup(func() { + cleanupActivities(t, issueID) + cleanupTestIssue(t, issueID) + }) + + agentID := testUserID + + bus.Publish(events.Event{ + Type: protocol.EventTaskFailed, + WorkspaceID: testWorkspaceID, + ActorType: "system", + ActorID: "", + Payload: map[string]any{ + "task_id": "00000000-0000-0000-0000-000000000002", + "agent_id": agentID, + "issue_id": issueID, + "status": "failed", + }, + }) + + activities := listActivitiesForIssue(t, queries, issueID) + if len(activities) != 1 { + t.Fatalf("expected 1 activity, got %d", len(activities)) + } + if activities[0].Action != "task_failed" { + t.Fatalf("expected action 'task_failed', got %q", activities[0].Action) + } +} diff --git a/server/cmd/server/listeners.go b/server/cmd/server/listeners.go index cddc60b5..9aeb0ca2 100644 --- a/server/cmd/server/listeners.go +++ b/server/cmd/server/listeners.go @@ -37,6 +37,7 @@ func registerListeners(bus *events.Bus, hub *realtime.Hub) { protocol.EventMemberRemoved, protocol.EventSubscriberAdded, protocol.EventSubscriberRemoved, + protocol.EventActivityCreated, } for _, et := range allEvents { diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 18e69a58..7f5bf1f2 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -55,6 +55,7 @@ func main() { // The notification listener queries the subscriber table to determine recipients, // so subscribers must be written first within the same synchronous event dispatch. registerSubscriberListeners(bus, queries) + registerActivityListeners(bus, queries) registerNotificationListeners(bus, queries) r := NewRouter(pool, hub, bus) 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/cmd/server/router.go b/server/cmd/server/router.go index d5401a9c..42350116 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -117,6 +117,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Delete("/", h.DeleteIssue) r.Post("/comments", h.CreateComment) r.Get("/comments", h.ListComments) + r.Get("/timeline", h.ListTimeline) r.Get("/subscribers", h.ListIssueSubscribers) r.Post("/subscribe", h.SubscribeToIssue) r.Post("/unsubscribe", h.UnsubscribeFromIssue) diff --git a/server/internal/handler/activity.go b/server/internal/handler/activity.go new file mode 100644 index 00000000..b7648834 --- /dev/null +++ b/server/internal/handler/activity.go @@ -0,0 +1,100 @@ +package handler + +import ( + "encoding/json" + "net/http" + "sort" + + "github.com/go-chi/chi/v5" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// TimelineEntry represents a single entry in the issue timeline, which can be +// either an activity log record or a comment. +type TimelineEntry struct { + Type string `json:"type"` // "activity" or "comment" + ID string `json:"id"` + + ActorType string `json:"actor_type"` + ActorID string `json:"actor_id"` + CreatedAt string `json:"created_at"` + + // Activity-only fields + Action *string `json:"action,omitempty"` + Details json.RawMessage `json:"details,omitempty"` + + // Comment-only fields + Content *string `json:"content,omitempty"` + ParentID *string `json:"parent_id,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` + CommentType *string `json:"comment_type,omitempty"` +} + +// ListTimeline returns a merged, chronologically-sorted timeline of activities +// and comments for a given issue. +func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + issue, ok := h.loadIssueForUser(w, r, id) + if !ok { + return + } + + activities, err := h.Queries.ListActivities(r.Context(), db.ListActivitiesParams{ + IssueID: issue.ID, + Limit: 200, + Offset: 0, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list activities") + return + } + + comments, err := h.Queries.ListComments(r.Context(), issue.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list comments") + return + } + + timeline := make([]TimelineEntry, 0, len(activities)+len(comments)) + + for _, a := range activities { + action := a.Action + actorType := "" + if a.ActorType.Valid { + actorType = a.ActorType.String + } + timeline = append(timeline, TimelineEntry{ + Type: "activity", + ID: uuidToString(a.ID), + ActorType: actorType, + ActorID: uuidToString(a.ActorID), + Action: &action, + Details: a.Details, + CreatedAt: timestampToString(a.CreatedAt), + }) + } + + for _, c := range comments { + content := c.Content + commentType := c.Type + updatedAt := timestampToString(c.UpdatedAt) + timeline = append(timeline, TimelineEntry{ + Type: "comment", + ID: uuidToString(c.ID), + ActorType: c.AuthorType, + ActorID: uuidToString(c.AuthorID), + Content: &content, + CommentType: &commentType, + ParentID: uuidToPtr(c.ParentID), + CreatedAt: timestampToString(c.CreatedAt), + UpdatedAt: &updatedAt, + }) + } + + // Sort chronologically (ascending by created_at) + sort.Slice(timeline, func(i, j int) bool { + return timeline[i].CreatedAt < timeline[j].CreatedAt + }) + + writeJSON(w, http.StatusOK, timeline) +} diff --git a/server/internal/handler/activity_test.go b/server/internal/handler/activity_test.go new file mode 100644 index 00000000..f2091181 --- /dev/null +++ b/server/internal/handler/activity_test.go @@ -0,0 +1,275 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +func TestListTimeline_MergedAndSorted(t *testing.T) { + ctx := context.Background() + + // Create an issue + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "Timeline test issue", + "status": "todo", + }) + testHandler.CreateIssue(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var issue IssueResponse + json.NewDecoder(w.Body).Decode(&issue) + issueID := issue.ID + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issueID) + testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID) + testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) + }) + + // Create an activity record directly in DB + _, err := testHandler.Queries.CreateActivity(ctx, db.CreateActivityParams{ + WorkspaceID: parseUUID(testWorkspaceID), + IssueID: parseUUID(issueID), + ActorType: strToText("member"), + ActorID: parseUUID(testUserID), + Action: "created", + Details: []byte("{}"), + }) + if err != nil { + t.Fatalf("CreateActivity: %v", err) + } + + // Create a comment + w = httptest.NewRecorder() + req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{ + "content": "Timeline test comment", + }) + req = withURLParam(req, "id", issueID) + testHandler.CreateComment(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String()) + } + + // Fetch timeline + w = httptest.NewRecorder() + req = newRequest("GET", "/api/issues/"+issueID+"/timeline", nil) + req = withURLParam(req, "id", issueID) + testHandler.ListTimeline(w, req) + if w.Code != http.StatusOK { + t.Fatalf("ListTimeline: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var timeline []TimelineEntry + json.NewDecoder(w.Body).Decode(&timeline) + if len(timeline) != 2 { + t.Fatalf("expected 2 timeline entries, got %d", len(timeline)) + } + + // First entry should be the activity (created earlier) + if timeline[0].Type != "activity" { + t.Fatalf("expected first entry type 'activity', got %q", timeline[0].Type) + } + if *timeline[0].Action != "created" { + t.Fatalf("expected action 'created', got %q", *timeline[0].Action) + } + + // Second entry should be the comment + if timeline[1].Type != "comment" { + t.Fatalf("expected second entry type 'comment', got %q", timeline[1].Type) + } + if *timeline[1].Content != "Timeline test comment" { + t.Fatalf("expected comment content 'Timeline test comment', got %q", *timeline[1].Content) + } +} + +func TestListTimeline_ChronologicalOrder(t *testing.T) { + ctx := context.Background() + + // Create an issue + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "Timeline order test issue", + "status": "todo", + }) + testHandler.CreateIssue(w, req) + var issue IssueResponse + json.NewDecoder(w.Body).Decode(&issue) + issueID := issue.ID + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issueID) + testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID) + testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) + }) + + // Create comment first + w = httptest.NewRecorder() + req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{ + "content": "First comment", + }) + req = withURLParam(req, "id", issueID) + testHandler.CreateComment(w, req) + + // Then create an activity after the comment + _, err := testHandler.Queries.CreateActivity(ctx, db.CreateActivityParams{ + WorkspaceID: parseUUID(testWorkspaceID), + IssueID: parseUUID(issueID), + ActorType: strToText("member"), + ActorID: parseUUID(testUserID), + Action: "status_changed", + Details: []byte(`{"from":"todo","to":"in_progress"}`), + }) + if err != nil { + t.Fatalf("CreateActivity: %v", err) + } + + // Fetch timeline + w = httptest.NewRecorder() + req = newRequest("GET", "/api/issues/"+issueID+"/timeline", nil) + req = withURLParam(req, "id", issueID) + testHandler.ListTimeline(w, req) + + var timeline []TimelineEntry + json.NewDecoder(w.Body).Decode(&timeline) + if len(timeline) != 2 { + t.Fatalf("expected 2 entries, got %d", len(timeline)) + } + + // Entries should be in chronological order + if timeline[0].CreatedAt > timeline[1].CreatedAt { + t.Fatalf("timeline not in chronological order: %s > %s", timeline[0].CreatedAt, timeline[1].CreatedAt) + } +} + +func TestCreateComment_WithParentID(t *testing.T) { + ctx := context.Background() + + // Create an issue + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "Reply test issue", + }) + testHandler.CreateIssue(w, req) + var issue IssueResponse + json.NewDecoder(w.Body).Decode(&issue) + issueID := issue.ID + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID) + testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) + }) + + // Create parent comment + w = httptest.NewRecorder() + req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{ + "content": "Parent comment", + }) + req = withURLParam(req, "id", issueID) + testHandler.CreateComment(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateComment (parent): expected 201, got %d: %s", w.Code, w.Body.String()) + } + var parentComment CommentResponse + json.NewDecoder(w.Body).Decode(&parentComment) + + // Create reply with parent_id + w = httptest.NewRecorder() + req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{ + "content": "Reply to parent", + "parent_id": parentComment.ID, + }) + req = withURLParam(req, "id", issueID) + testHandler.CreateComment(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateComment (reply): expected 201, got %d: %s", w.Code, w.Body.String()) + } + var replyComment CommentResponse + json.NewDecoder(w.Body).Decode(&replyComment) + + if replyComment.ParentID == nil { + t.Fatal("expected reply to have parent_id set") + } + if *replyComment.ParentID != parentComment.ID { + t.Fatalf("expected parent_id %q, got %q", parentComment.ID, *replyComment.ParentID) + } + + // Verify parent comment has no parent_id + if parentComment.ParentID != nil { + t.Fatalf("expected parent comment to have nil parent_id, got %q", *parentComment.ParentID) + } +} + +func TestCommentWithParentID_AppearsInTimeline(t *testing.T) { + ctx := context.Background() + + // Create an issue + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "Timeline reply test", + }) + testHandler.CreateIssue(w, req) + var issue IssueResponse + json.NewDecoder(w.Body).Decode(&issue) + issueID := issue.ID + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issueID) + testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID) + testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) + }) + + // Create parent comment + w = httptest.NewRecorder() + req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{ + "content": "Parent in timeline", + }) + req = withURLParam(req, "id", issueID) + testHandler.CreateComment(w, req) + var parent CommentResponse + json.NewDecoder(w.Body).Decode(&parent) + + // Create reply + w = httptest.NewRecorder() + req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{ + "content": "Reply in timeline", + "parent_id": parent.ID, + }) + req = withURLParam(req, "id", issueID) + testHandler.CreateComment(w, req) + + // Fetch timeline + w = httptest.NewRecorder() + req = newRequest("GET", "/api/issues/"+issueID+"/timeline", nil) + req = withURLParam(req, "id", issueID) + testHandler.ListTimeline(w, req) + if w.Code != http.StatusOK { + t.Fatalf("ListTimeline: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var timeline []TimelineEntry + json.NewDecoder(w.Body).Decode(&timeline) + if len(timeline) != 2 { + t.Fatalf("expected 2 timeline entries, got %d", len(timeline)) + } + + // Find the reply entry + var found bool + for _, entry := range timeline { + if entry.Type == "comment" && entry.ParentID != nil && *entry.ParentID == parent.ID { + found = true + if *entry.Content != "Reply in timeline" { + t.Fatalf("expected reply content 'Reply in timeline', got %q", *entry.Content) + } + } + } + if !found { + t.Fatal("expected to find reply with parent_id in timeline") + } +} diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index ea40c200..be6adb9f 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -6,20 +6,22 @@ import ( "net/http" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/logger" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" ) type CommentResponse struct { - ID string `json:"id"` - IssueID string `json:"issue_id"` - AuthorType string `json:"author_type"` - AuthorID string `json:"author_id"` - Content string `json:"content"` - Type string `json:"type"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + IssueID string `json:"issue_id"` + AuthorType string `json:"author_type"` + AuthorID string `json:"author_id"` + Content string `json:"content"` + Type string `json:"type"` + ParentID *string `json:"parent_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } func commentToResponse(c db.Comment) CommentResponse { @@ -30,6 +32,7 @@ func commentToResponse(c db.Comment) CommentResponse { AuthorID: uuidToString(c.AuthorID), Content: c.Content, Type: c.Type, + ParentID: uuidToPtr(c.ParentID), CreatedAt: timestampToString(c.CreatedAt), UpdatedAt: timestampToString(c.UpdatedAt), } @@ -57,8 +60,9 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) { } type CreateCommentRequest struct { - Content string `json:"content"` - Type string `json:"type"` + Content string `json:"content"` + Type string `json:"type"` + ParentID *string `json:"parent_id"` } func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { @@ -87,12 +91,23 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { req.Type = "comment" } + var parentID pgtype.UUID + if req.ParentID != nil { + parentID = parseUUID(*req.ParentID) + parent, err := h.Queries.GetComment(r.Context(), parentID) + if err != nil || uuidToString(parent.IssueID) != issueID { + writeError(w, http.StatusBadRequest, "invalid parent comment") + return + } + } + comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{ IssueID: issue.ID, AuthorType: "member", AuthorID: parseUUID(userID), Content: req.Content, Type: req.Type, + ParentID: parentID, }) if err != nil { slog.Warn("create comment failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...) 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/017_comment_parent_id.down.sql b/server/migrations/017_comment_parent_id.down.sql new file mode 100644 index 00000000..3f63f1a9 --- /dev/null +++ b/server/migrations/017_comment_parent_id.down.sql @@ -0,0 +1 @@ +ALTER TABLE comment DROP COLUMN parent_id; diff --git a/server/migrations/017_comment_parent_id.up.sql b/server/migrations/017_comment_parent_id.up.sql new file mode 100644 index 00000000..c9b1e87c --- /dev/null +++ b/server/migrations/017_comment_parent_id.up.sql @@ -0,0 +1 @@ +ALTER TABLE comment ADD COLUMN parent_id UUID REFERENCES comment(id) ON DELETE SET NULL; diff --git a/server/migrations/018_comment_parent_cascade.down.sql b/server/migrations/018_comment_parent_cascade.down.sql new file mode 100644 index 00000000..ef56c144 --- /dev/null +++ b/server/migrations/018_comment_parent_cascade.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE comment DROP CONSTRAINT IF EXISTS comment_parent_id_fkey; +ALTER TABLE comment ADD CONSTRAINT comment_parent_id_fkey + FOREIGN KEY (parent_id) REFERENCES comment(id) ON DELETE SET NULL; diff --git a/server/migrations/018_comment_parent_cascade.up.sql b/server/migrations/018_comment_parent_cascade.up.sql new file mode 100644 index 00000000..986e6863 --- /dev/null +++ b/server/migrations/018_comment_parent_cascade.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE comment DROP CONSTRAINT IF EXISTS comment_parent_id_fkey; +ALTER TABLE comment ADD CONSTRAINT comment_parent_id_fkey + FOREIGN KEY (parent_id) REFERENCES comment(id) ON DELETE CASCADE; 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/activity.sql.go b/server/pkg/db/generated/activity.sql.go index 87fffefb..af2394d9 100644 --- a/server/pkg/db/generated/activity.sql.go +++ b/server/pkg/db/generated/activity.sql.go @@ -53,7 +53,7 @@ func (q *Queries) CreateActivity(ctx context.Context, arg CreateActivityParams) const listActivities = `-- name: ListActivities :many SELECT id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at FROM activity_log WHERE issue_id = $1 -ORDER BY created_at DESC +ORDER BY created_at ASC LIMIT $2 OFFSET $3 ` diff --git a/server/pkg/db/generated/comment.sql.go b/server/pkg/db/generated/comment.sql.go index 9675f23d..0217aee3 100644 --- a/server/pkg/db/generated/comment.sql.go +++ b/server/pkg/db/generated/comment.sql.go @@ -12,9 +12,9 @@ import ( ) const createComment = `-- name: CreateComment :one -INSERT INTO comment (issue_id, author_type, author_id, content, type) -VALUES ($1, $2, $3, $4, $5) -RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at +INSERT INTO comment (issue_id, author_type, author_id, content, type, parent_id) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id ` type CreateCommentParams struct { @@ -23,6 +23,7 @@ type CreateCommentParams struct { AuthorID pgtype.UUID `json:"author_id"` Content string `json:"content"` Type string `json:"type"` + ParentID pgtype.UUID `json:"parent_id"` } func (q *Queries) CreateComment(ctx context.Context, arg CreateCommentParams) (Comment, error) { @@ -32,6 +33,7 @@ func (q *Queries) CreateComment(ctx context.Context, arg CreateCommentParams) (C arg.AuthorID, arg.Content, arg.Type, + arg.ParentID, ) var i Comment err := row.Scan( @@ -43,6 +45,7 @@ func (q *Queries) CreateComment(ctx context.Context, arg CreateCommentParams) (C &i.Type, &i.CreatedAt, &i.UpdatedAt, + &i.ParentID, ) return i, err } @@ -57,7 +60,7 @@ func (q *Queries) DeleteComment(ctx context.Context, id pgtype.UUID) error { } const getComment = `-- name: GetComment :one -SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at FROM comment +SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id FROM comment WHERE id = $1 ` @@ -73,12 +76,13 @@ func (q *Queries) GetComment(ctx context.Context, id pgtype.UUID) (Comment, erro &i.Type, &i.CreatedAt, &i.UpdatedAt, + &i.ParentID, ) return i, err } const listComments = `-- name: ListComments :many -SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at FROM comment +SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id FROM comment WHERE issue_id = $1 ORDER BY created_at ASC ` @@ -101,6 +105,7 @@ func (q *Queries) ListComments(ctx context.Context, issueID pgtype.UUID) ([]Comm &i.Type, &i.CreatedAt, &i.UpdatedAt, + &i.ParentID, ); err != nil { return nil, err } @@ -117,7 +122,7 @@ UPDATE comment SET content = $2, updated_at = now() WHERE id = $1 -RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at +RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id ` type UpdateCommentParams struct { @@ -137,6 +142,7 @@ func (q *Queries) UpdateComment(ctx context.Context, arg UpdateCommentParams) (C &i.Type, &i.CreatedAt, &i.UpdatedAt, + &i.ParentID, ) return i, err } 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 14fd5d05..546a3bad 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -84,6 +84,7 @@ type Comment struct { Type string `json:"type"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ParentID pgtype.UUID `json:"parent_id"` } type DaemonConnection struct { @@ -130,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/activity.sql b/server/pkg/db/queries/activity.sql index 3e6dc39b..0e2dde33 100644 --- a/server/pkg/db/queries/activity.sql +++ b/server/pkg/db/queries/activity.sql @@ -1,7 +1,7 @@ -- name: ListActivities :many SELECT * FROM activity_log WHERE issue_id = $1 -ORDER BY created_at DESC +ORDER BY created_at ASC LIMIT $2 OFFSET $3; -- name: CreateActivity :one diff --git a/server/pkg/db/queries/comment.sql b/server/pkg/db/queries/comment.sql index 7666de4f..4648ad02 100644 --- a/server/pkg/db/queries/comment.sql +++ b/server/pkg/db/queries/comment.sql @@ -8,8 +8,8 @@ SELECT * FROM comment WHERE id = $1; -- name: CreateComment :one -INSERT INTO comment (issue_id, author_type, author_id, content, type) -VALUES ($1, $2, $3, $4, $5) +INSERT INTO comment (issue_id, author_type, author_id, content, type, parent_id) +VALUES ($1, $2, $3, $4, $5, sqlc.narg(parent_id)) RETURNING *; -- name: UpdateComment :one 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 diff --git a/server/pkg/protocol/events.go b/server/pkg/protocol/events.go index 137151d7..6029bf2a 100644 --- a/server/pkg/protocol/events.go +++ b/server/pkg/protocol/events.go @@ -43,6 +43,9 @@ const ( EventSubscriberAdded = "subscriber:added" EventSubscriberRemoved = "subscriber:removed" + // Activity events + EventActivityCreated = "activity:created" + // Daemon events EventDaemonHeartbeat = "daemon:heartbeat" EventDaemonRegister = "daemon:register"