From 7c1aabbe3a7d8bc2805caab654c8c9e9c05c0978 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Mon, 30 Mar 2026 22:37:59 +0800 Subject: [PATCH] feat(reactions): add emoji reactions for comments and issue descriptions Add Slack-style emoji reactions to comments and issue descriptions with full-stack support: database tables, REST API endpoints, real-time WebSocket sync, optimistic UI updates, and inbox notifications. - New `comment_reaction` and `issue_reaction` tables with migrations - POST/DELETE endpoints for adding/removing reactions on both comments and issue descriptions - Real-time WS events (reaction:added/removed, issue_reaction:added/removed) - Shared ReactionBar component with quick emoji picker and full emoji-mart picker (lazy-loaded) - Optimistic add/remove with rollback on failure - Inbox notifications for comment author and issue creator when reacted to - Reactions included in timeline, comment list, and issue detail responses --- apps/web/app/(dashboard)/inbox/page.tsx | 6 + .../app/(dashboard)/issues/[id]/page.test.tsx | 1 + apps/web/components/common/emoji-picker.tsx | 42 +++++ apps/web/components/common/reaction-bar.tsx | 144 +++++++++++++++ .../issues/components/comment-card.tsx | 25 ++- .../issues/components/issue-detail.tsx | 168 ++++++++++++++++- apps/web/package.json | 2 + apps/web/shared/api/client.ts | 30 ++++ apps/web/shared/types/activity.ts | 3 + apps/web/shared/types/comment.ts | 10 ++ apps/web/shared/types/events.ts | 35 +++- apps/web/shared/types/inbox.ts | 3 +- apps/web/shared/types/index.ts | 4 +- apps/web/shared/types/issue.ts | 10 ++ pnpm-lock.yaml | 16 ++ server/cmd/server/notification_listeners.go | 70 ++++++++ server/cmd/server/router.go | 4 + server/internal/handler/activity.go | 18 +- server/internal/handler/comment.go | 39 ++-- server/internal/handler/issue.go | 48 +++-- server/internal/handler/issue_reaction.go | 131 ++++++++++++++ server/internal/handler/reaction.go | 169 ++++++++++++++++++ .../migrations/026_comment_reactions.down.sql | 1 + .../migrations/026_comment_reactions.up.sql | 12 ++ .../migrations/027_issue_reactions.down.sql | 1 + server/migrations/027_issue_reactions.up.sql | 12 ++ server/pkg/db/generated/issue_reaction.sql.go | 104 +++++++++++ server/pkg/db/generated/models.go | 20 +++ server/pkg/db/generated/reaction.sql.go | 104 +++++++++++ server/pkg/db/queries/issue_reaction.sql | 14 ++ server/pkg/db/queries/reaction.sql | 14 ++ server/pkg/protocol/events.go | 10 +- 32 files changed, 1221 insertions(+), 49 deletions(-) create mode 100644 apps/web/components/common/emoji-picker.tsx create mode 100644 apps/web/components/common/reaction-bar.tsx create mode 100644 server/internal/handler/issue_reaction.go create mode 100644 server/internal/handler/reaction.go create mode 100644 server/migrations/026_comment_reactions.down.sql create mode 100644 server/migrations/026_comment_reactions.up.sql create mode 100644 server/migrations/027_issue_reactions.down.sql create mode 100644 server/migrations/027_issue_reactions.up.sql create mode 100644 server/pkg/db/generated/issue_reaction.sql.go create mode 100644 server/pkg/db/generated/reaction.sql.go create mode 100644 server/pkg/db/queries/issue_reaction.sql create mode 100644 server/pkg/db/queries/reaction.sql diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index e00ba229..867a1b07 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -52,6 +52,7 @@ const typeLabels: Record = { task_failed: "Task failed", agent_blocked: "Agent blocked", agent_completed: "Agent completed", + reaction_added: "Reacted", }; function timeAgo(dateStr: string): string { @@ -126,6 +127,11 @@ function InboxDetailLabel({ item }: { item: InboxItem }) { if (item.body) return {item.body}; return {typeLabels[item.type]}; } + case "reaction_added": { + const emoji = details.emoji; + if (emoji) return Reacted {emoji} to your comment; + return {typeLabels[item.type]}; + } default: return {typeLabels[item.type] ?? item.type}; } diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index b6b6b151..c9ffdb1d 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -291,6 +291,7 @@ describe("IssueDetailPage", () => { author_type: "member", author_id: "user-1", parent_id: null, + reactions: [], created_at: "2026-01-18T00:00:00Z", updated_at: "2026-01-18T00:00:00Z", }; diff --git a/apps/web/components/common/emoji-picker.tsx b/apps/web/components/common/emoji-picker.tsx new file mode 100644 index 00000000..93b12824 --- /dev/null +++ b/apps/web/components/common/emoji-picker.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; +import data from "@emoji-mart/data"; +import { Picker } from "emoji-mart"; + +interface EmojiPickerProps { + onSelect: (emoji: string) => void; +} + +export function EmojiPicker({ onSelect }: EmojiPickerProps) { + const containerRef = useRef(null); + const onSelectRef = useRef(onSelect); + onSelectRef.current = onSelect; + + const handleSelect = useCallback((emoji: { native: string }) => { + onSelectRef.current(emoji.native); + }, []); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const picker = new Picker({ + data, + onEmojiSelect: handleSelect, + theme: "auto", + set: "native", + previewPosition: "none", + skinTonePosition: "search", + maxFrequentRows: 2, + }); + + container.appendChild(picker as unknown as Node); + + return () => { + container.replaceChildren(); + }; + }, [handleSelect]); + + return
; +} diff --git a/apps/web/components/common/reaction-bar.tsx b/apps/web/components/common/reaction-bar.tsx new file mode 100644 index 00000000..fa98a5fc --- /dev/null +++ b/apps/web/components/common/reaction-bar.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useState, lazy, Suspense } from "react"; +import { SmilePlus } from "lucide-react"; +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { useActorName } from "@/features/workspace"; + +const EmojiPicker = lazy(() => + import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })), +); + +const QUICK_EMOJIS = ["👍", "👎", "❤️", "😄", "🎉", "😕", "🚀", "👀"]; + +interface ReactionItem { + id: string; + actor_type: string; + actor_id: string; + emoji: string; +} + +interface GroupedReaction { + emoji: string; + count: number; + reacted: boolean; + actors: { type: string; id: string }[]; +} + +function groupReactions(reactions: ReactionItem[], currentUserId?: string): GroupedReaction[] { + const map = new Map(); + for (const r of reactions) { + let group = map.get(r.emoji); + if (!group) { + group = { emoji: r.emoji, count: 0, reacted: false, actors: [] }; + map.set(r.emoji, group); + } + group.count++; + group.actors.push({ type: r.actor_type, id: r.actor_id }); + if (r.actor_type === "member" && r.actor_id === currentUserId) { + group.reacted = true; + } + } + return Array.from(map.values()); +} + +export function ReactionBar({ + reactions, + currentUserId, + onToggle, + className, +}: { + reactions: ReactionItem[]; + currentUserId?: string; + onToggle: (emoji: string) => void; + className?: string; +}) { + const [pickerOpen, setPickerOpen] = useState(false); + const [showFullPicker, setShowFullPicker] = useState(false); + const grouped = groupReactions(reactions, currentUserId); + const { getActorName } = useActorName(); + + const handlePickerOpenChange = (open: boolean) => { + setPickerOpen(open); + if (!open) setShowFullPicker(false); + }; + + return ( +
+ {grouped.map((g) => ( + + onToggle(g.emoji)} + className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors hover:bg-accent ${ + g.reacted + ? "border-primary/40 bg-primary/10 text-primary" + : "border-border text-muted-foreground" + }`} + > + {g.emoji} + {g.count} + + } + /> + + {g.actors.map((a) => getActorName(a.type, a.id)).join(", ")} + + + ))} + + + + + } + /> + + {showFullPicker ? ( + Loading...
}> + { + onToggle(emoji); + setPickerOpen(false); + setShowFullPicker(false); + }} + /> + + ) : ( +
+
+ {QUICK_EMOJIS.map((emoji) => ( + + ))} +
+ +
+ )} + + +
+ ); +} diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index 5db099fb..eef902d4 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { ActorAvatar } from "@/components/common/actor-avatar"; +import { ReactionBar } from "@/components/common/reaction-bar"; import { Markdown } from "@/components/markdown"; import { useActorName } from "@/features/workspace"; import { timeAgo } from "@/shared/utils"; @@ -31,6 +32,7 @@ interface CommentCardProps { onReply: (parentId: string, content: string) => Promise; onEdit: (commentId: string, content: string) => Promise; onDelete: (commentId: string) => void; + onToggleReaction: (commentId: string, emoji: string) => void; } // --------------------------------------------------------------------------- @@ -42,11 +44,13 @@ function CommentRow({ currentUserId, onEdit, onDelete, + onToggleReaction, }: { entry: TimelineEntry; currentUserId?: string; onEdit: (commentId: string, content: string) => Promise; onDelete: (commentId: string) => void; + onToggleReaction: (commentId: string, emoji: string) => void; }) { const { getActorName } = useActorName(); const [editing, setEditing] = useState(false); @@ -77,6 +81,8 @@ function CommentRow({ } }; + const reactions = entry.reactions ?? []; + return (
@@ -136,9 +142,19 @@ function CommentRow({
) : ( -
- {entry.content ?? ""} -
+ <> +
+ {entry.content ?? ""} +
+ {!isTemp && ( + onToggleReaction(entry.id, emoji)} + className="mt-1.5 pl-8" + /> + )} + )}
); @@ -155,6 +171,7 @@ function CommentCard({ onReply, onEdit, onDelete, + onToggleReaction, }: CommentCardProps) { // Collect all nested replies recursively into a flat list const allNestedReplies: TimelineEntry[] = []; @@ -176,6 +193,7 @@ function CommentCard({ currentUserId={currentUserId} onEdit={onEdit} onDelete={onDelete} + onToggleReaction={onToggleReaction} /> @@ -187,6 +205,7 @@ function CommentCard({ currentUserId={currentUserId} onEdit={onEdit} onDelete={onDelete} + onToggleReaction={onToggleReaction} /> ))} diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 987d4f77..461962c2 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -55,7 +55,7 @@ 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, TimelineEntry } from "@/shared/types"; +import type { Issue, IssueReaction, 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"; @@ -65,7 +65,8 @@ 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, ActivityCreatedPayload } from "@/shared/types"; +import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload, SubscriberAddedPayload, SubscriberRemovedPayload, ActivityCreatedPayload, ReactionAddedPayload, ReactionRemovedPayload, IssueReactionAddedPayload, IssueReactionRemovedPayload } from "@/shared/types"; +import { ReactionBar } from "@/components/common/reaction-bar"; import { timeAgo } from "@/shared/utils"; function shortDate(date: string | null): string { @@ -135,6 +136,7 @@ function commentToTimelineEntry(c: Comment): TimelineEntry { created_at: c.created_at, updated_at: c.updated_at, comment_type: c.type, + reactions: c.reactions ?? [], }; } @@ -195,6 +197,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const sidebarRef = usePanelRef(); const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen); const [issue, setIssue] = useState(null); + const [issueReactions, setIssueReactions] = useState([]); const [timeline, setTimeline] = useState([]); const [subscribers, setSubscribers] = useState([]); const [loading, setLoading] = useState(true); @@ -228,12 +231,14 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo wasLoadedRef.current = false; setIssue(null); setTitleDraft(""); + setIssueReactions([]); setTimeline([]); setSubscribers([]); setLoading(true); Promise.all([api.getIssue(id), api.listTimeline(id), api.listIssueSubscribers(id)]) .then(([iss, entries, subs]) => { setIssue(iss); + setIssueReactions(iss.reactions ?? []); setTitleDraft(iss.title); setTimeline(entries); setSubscribers(subs); @@ -430,6 +435,157 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo }, [id]), ); + // Real-time reaction updates + useWSEvent( + "reaction:added", + useCallback((payload: unknown) => { + const { reaction, issue_id } = payload as ReactionAddedPayload; + if (issue_id !== id) return; + // Skip own reactions — already added optimistically + if (reaction.actor_type === "member" && reaction.actor_id === user?.id) return; + setTimeline((prev) => prev.map((e) => { + if (e.id !== reaction.comment_id) return e; + const existing = e.reactions ?? []; + if (existing.some((r) => r.id === reaction.id)) return e; + return { ...e, reactions: [...existing, reaction] }; + })); + }, [id, user?.id]), + ); + + useWSEvent( + "reaction:removed", + useCallback((payload: unknown) => { + const p = payload as ReactionRemovedPayload; + if (p.issue_id !== id) return; + // Skip own removals — already removed optimistically + if (p.actor_type === "member" && p.actor_id === user?.id) return; + setTimeline((prev) => prev.map((e) => { + if (e.id !== p.comment_id) return e; + return { + ...e, + reactions: (e.reactions ?? []).filter( + (r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id), + ), + }; + })); + }, [id, user?.id]), + ); + + // Real-time issue reaction updates + useWSEvent( + "issue_reaction:added", + useCallback((payload: unknown) => { + const { reaction, issue_id } = payload as IssueReactionAddedPayload; + if (issue_id !== id) return; + if (reaction.actor_type === "member" && reaction.actor_id === user?.id) return; + setIssueReactions((prev) => { + if (prev.some((r) => r.id === reaction.id)) return prev; + return [...prev, reaction]; + }); + }, [id, user?.id]), + ); + + useWSEvent( + "issue_reaction:removed", + useCallback((payload: unknown) => { + const p = payload as IssueReactionRemovedPayload; + if (p.issue_id !== id) return; + if (p.actor_type === "member" && p.actor_id === user?.id) return; + setIssueReactions((prev) => + prev.filter((r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id)), + ); + }, [id, user?.id]), + ); + + const handleToggleIssueReaction = async (emoji: string) => { + if (!user) return; + const existing = issueReactions.find( + (r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === user.id, + ); + if (existing) { + setIssueReactions((prev) => prev.filter((r) => r.id !== existing.id)); + try { + await api.removeIssueReaction(id, emoji); + } catch { + setIssueReactions((prev) => [...prev, existing]); + toast.error("Failed to remove reaction"); + } + } else { + const temp = { + id: `temp-${Date.now()}`, + issue_id: id, + actor_type: "member", + actor_id: user.id, + emoji, + created_at: new Date().toISOString(), + }; + setIssueReactions((prev) => [...prev, temp]); + try { + const reaction = await api.addIssueReaction(id, emoji); + setIssueReactions((prev) => prev.map((r) => (r.id === temp.id ? reaction : r))); + } catch { + setIssueReactions((prev) => prev.filter((r) => r.id !== temp.id)); + toast.error("Failed to add reaction"); + } + } + }; + + const handleToggleReaction = async (commentId: string, emoji: string) => { + if (!user) return; + const entry = timeline.find((e) => e.id === commentId); + const existing = (entry?.reactions ?? []).find( + (r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === user.id, + ); + if (existing) { + // Optimistic remove + setTimeline((prev) => prev.map((e) => { + if (e.id !== commentId) return e; + return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== existing.id) }; + })); + try { + await api.removeReaction(commentId, emoji); + } catch { + // Rollback + setTimeline((prev) => prev.map((e) => { + if (e.id !== commentId) return e; + return { ...e, reactions: [...(e.reactions ?? []), existing] }; + })); + toast.error("Failed to remove reaction"); + } + } else { + // Optimistic add + const tempReaction = { + id: `temp-${Date.now()}`, + comment_id: commentId, + actor_type: "member", + actor_id: user.id, + emoji, + created_at: new Date().toISOString(), + }; + setTimeline((prev) => prev.map((e) => { + if (e.id !== commentId) return e; + return { ...e, reactions: [...(e.reactions ?? []), tempReaction] }; + })); + try { + const reaction = await api.addReaction(commentId, emoji); + setTimeline((prev) => prev.map((e) => { + if (e.id !== commentId) return e; + return { + ...e, + reactions: (e.reactions ?? []).map((r) => (r.id === tempReaction.id ? reaction : r)), + }; + })); + } catch { + // Rollback + setTimeline((prev) => prev.map((e) => { + if (e.id !== commentId) return e; + return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== tempReaction.id) }; + })); + toast.error("Failed to add reaction"); + } + } + }; + // Real-time subscriber updates useWSEvent( "subscriber:added", @@ -770,6 +926,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo className="mt-5" /> + +
{/* Activity / Comments */} @@ -917,6 +1080,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo onReply={handleSubmitReply} onEdit={handleEditComment} onDelete={handleDeleteComment} + onToggleReaction={handleToggleReaction} /> ); } diff --git a/apps/web/package.json b/apps/web/package.json index b58ef46e..70b42d87 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@emoji-mart/data": "^1.2.1", "@tiptap/extension-link": "^3.20.5", "@tiptap/extension-mention": "^3.20.5", "@tiptap/extension-placeholder": "^3.20.5", @@ -29,6 +30,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", + "emoji-mart": "^5.6.0", "input-otp": "^1.4.2", "linkify-it": "^5.0.0", "lucide-react": "catalog:", diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 5516621b..b890a19f 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -17,6 +17,8 @@ import type { InboxItem, IssueSubscriber, Comment, + Reaction, + IssueReaction, Workspace, WorkspaceRepo, MemberWithUser, @@ -225,6 +227,34 @@ export class ApiClient { await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" }); } + async addReaction(commentId: string, emoji: string): Promise { + return this.fetch(`/api/comments/${commentId}/reactions`, { + method: "POST", + body: JSON.stringify({ emoji }), + }); + } + + async removeReaction(commentId: string, emoji: string): Promise { + await this.fetch(`/api/comments/${commentId}/reactions`, { + method: "DELETE", + body: JSON.stringify({ emoji }), + }); + } + + async addIssueReaction(issueId: string, emoji: string): Promise { + return this.fetch(`/api/issues/${issueId}/reactions`, { + method: "POST", + body: JSON.stringify({ emoji }), + }); + } + + async removeIssueReaction(issueId: string, emoji: string): Promise { + await this.fetch(`/api/issues/${issueId}/reactions`, { + method: "DELETE", + body: JSON.stringify({ emoji }), + }); + } + // Subscribers async listIssueSubscribers(issueId: string): Promise { return this.fetch(`/api/issues/${issueId}/subscribers`); diff --git a/apps/web/shared/types/activity.ts b/apps/web/shared/types/activity.ts index 335fce8a..5dc2e9fa 100644 --- a/apps/web/shared/types/activity.ts +++ b/apps/web/shared/types/activity.ts @@ -1,3 +1,5 @@ +import type { Reaction } from "./comment"; + export interface TimelineEntry { type: "activity" | "comment"; id: string; @@ -12,4 +14,5 @@ export interface TimelineEntry { parent_id?: string | null; updated_at?: string; comment_type?: string; + reactions?: Reaction[]; } diff --git a/apps/web/shared/types/comment.ts b/apps/web/shared/types/comment.ts index 8ce17d77..bd2a4b57 100644 --- a/apps/web/shared/types/comment.ts +++ b/apps/web/shared/types/comment.ts @@ -2,6 +2,15 @@ export type CommentType = "comment" | "status_change" | "progress_update" | "sys export type CommentAuthorType = "member" | "agent"; +export interface Reaction { + id: string; + comment_id: string; + actor_type: string; + actor_id: string; + emoji: string; + created_at: string; +} + export interface Comment { id: string; issue_id: string; @@ -10,6 +19,7 @@ export interface Comment { content: string; type: CommentType; parent_id: string | null; + reactions: Reaction[]; created_at: string; updated_at: string; } diff --git a/apps/web/shared/types/events.ts b/apps/web/shared/types/events.ts index 15991d07..c9fb5a59 100644 --- a/apps/web/shared/types/events.ts +++ b/apps/web/shared/types/events.ts @@ -1,7 +1,7 @@ -import type { Issue } from "./issue"; +import type { Issue, IssueReaction } from "./issue"; import type { Agent } from "./agent"; import type { InboxItem } from "./inbox"; -import type { Comment } from "./comment"; +import type { Comment, Reaction } from "./comment"; import type { TimelineEntry } from "./activity"; import type { Workspace, MemberWithUser } from "./workspace"; @@ -37,7 +37,11 @@ export type WSEventType = | "skill:deleted" | "subscriber:added" | "subscriber:removed" - | "activity:created"; + | "activity:created" + | "reaction:added" + | "reaction:removed" + | "issue_reaction:added" + | "issue_reaction:removed"; export interface WSMessage { type: WSEventType; @@ -147,3 +151,28 @@ export interface ActivityCreatedPayload { issue_id: string; entry: TimelineEntry; } + +export interface ReactionAddedPayload { + reaction: Reaction; + issue_id: string; +} + +export interface ReactionRemovedPayload { + comment_id: string; + issue_id: string; + emoji: string; + actor_type: string; + actor_id: string; +} + +export interface IssueReactionAddedPayload { + reaction: IssueReaction; + issue_id: string; +} + +export interface IssueReactionRemovedPayload { + issue_id: string; + emoji: string; + actor_type: string; + actor_id: string; +} diff --git a/apps/web/shared/types/inbox.ts b/apps/web/shared/types/inbox.ts index b9a901fa..3a894ac3 100644 --- a/apps/web/shared/types/inbox.ts +++ b/apps/web/shared/types/inbox.ts @@ -15,7 +15,8 @@ export type InboxItemType = | "task_completed" | "task_failed" | "agent_blocked" - | "agent_completed"; + | "agent_completed" + | "reaction_added"; export interface InboxItem { id: string; diff --git a/apps/web/shared/types/index.ts b/apps/web/shared/types/index.ts index 7bc3d362..5ef60118 100644 --- a/apps/web/shared/types/index.ts +++ b/apps/web/shared/types/index.ts @@ -1,4 +1,4 @@ -export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue"; +export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueReaction } from "./issue"; export type { Agent, AgentStatus, @@ -24,7 +24,7 @@ export type { } from "./agent"; 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 { Comment, CommentType, CommentAuthorType, Reaction } from "./comment"; export type { TimelineEntry } from "./activity"; export type { IssueSubscriber } from "./subscriber"; export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon"; diff --git a/apps/web/shared/types/issue.ts b/apps/web/shared/types/issue.ts index 808d569d..acbab774 100644 --- a/apps/web/shared/types/issue.ts +++ b/apps/web/shared/types/issue.ts @@ -11,6 +11,15 @@ export type IssuePriority = "urgent" | "high" | "medium" | "low" | "none"; export type IssueAssigneeType = "member" | "agent"; +export interface IssueReaction { + id: string; + issue_id: string; + actor_type: string; + actor_id: string; + emoji: string; + created_at: string; +} + export interface Issue { id: string; workspace_id: string; @@ -27,6 +36,7 @@ export interface Issue { parent_issue_id: string | null; position: number; due_date: string | null; + reactions?: IssueReaction[]; created_at: string; updated_at: string; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb25de27..34e8ca80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.3) + '@emoji-mart/data': + specifier: ^1.2.1 + version: 1.2.1 '@tiptap/extension-link': specifier: ^3.20.5 version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5) @@ -108,6 +111,9 @@ importers: embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.3) + emoji-mart: + specifier: ^5.6.0 + version: 5.6.0 input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -464,6 +470,9 @@ packages: '@emnapi/wasi-threads@1.2.0': resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@emoji-mart/data@1.2.1': + resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} + '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2010,6 +2019,9 @@ packages: embla-carousel@8.6.0: resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emoji-mart@5.6.0: + resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -4232,6 +4244,8 @@ snapshots: tslib: 2.8.1 optional: true + '@emoji-mart/data@1.2.1': {} + '@exodus/bytes@1.15.0(@noble/hashes@1.8.0)': optionalDependencies: '@noble/hashes': 1.8.0 @@ -5567,6 +5581,8 @@ snapshots: embla-carousel@8.6.0: {} + emoji-mart@5.6.0: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} diff --git a/server/cmd/server/notification_listeners.go b/server/cmd/server/notification_listeners.go index f1e1503b..0f471806 100644 --- a/server/cmd/server/notification_listeners.go +++ b/server/cmd/server/notification_listeners.go @@ -473,6 +473,76 @@ func registerNotificationListeners(bus *events.Bus, queries *db.Queries) { } }) + // issue_reaction:added — notify the issue creator + bus.Subscribe(protocol.EventIssueReactionAdded, func(e events.Event) { + payload, ok := e.Payload.(map[string]any) + if !ok { + return + } + + reaction, ok := payload["reaction"].(handler.IssueReactionResponse) + if !ok { + return + } + + creatorType, _ := payload["creator_type"].(string) + creatorID, _ := payload["creator_id"].(string) + issueID, _ := payload["issue_id"].(string) + issueTitle, _ := payload["issue_title"].(string) + issueStatus, _ := payload["issue_status"].(string) + + if creatorType == "" || creatorID == "" { + return + } + + details, _ := json.Marshal(map[string]string{ + "emoji": reaction.Emoji, + }) + + notifyDirect(ctx, queries, bus, + creatorType, creatorID, + e.WorkspaceID, e, issueID, issueStatus, + "reaction_added", "info", + issueTitle, "", + details, + ) + }) + + // reaction:added — notify the comment author + bus.Subscribe(protocol.EventReactionAdded, func(e events.Event) { + payload, ok := e.Payload.(map[string]any) + if !ok { + return + } + + reaction, ok := payload["reaction"].(handler.ReactionResponse) + if !ok { + return + } + + commentAuthorType, _ := payload["comment_author_type"].(string) + commentAuthorID, _ := payload["comment_author_id"].(string) + issueID, _ := payload["issue_id"].(string) + issueTitle, _ := payload["issue_title"].(string) + issueStatus, _ := payload["issue_status"].(string) + + if commentAuthorType == "" || commentAuthorID == "" { + return + } + + details, _ := json.Marshal(map[string]string{ + "emoji": reaction.Emoji, + }) + + notifyDirect(ctx, queries, bus, + commentAuthorType, commentAuthorID, + e.WorkspaceID, e, issueID, issueStatus, + "reaction_added", "info", + issueTitle, "", + details, + ) + }) + // task:completed — notify all subscribers except the agent bus.Subscribe(protocol.EventTaskCompleted, func(e events.Event) { payload, ok := e.Payload.(map[string]any) diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 8797a35b..e7cfaf23 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -164,6 +164,8 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Get("/subscribers", h.ListIssueSubscribers) r.Post("/subscribe", h.SubscribeToIssue) r.Post("/unsubscribe", h.UnsubscribeFromIssue) + r.Post("/reactions", h.AddIssueReaction) + r.Delete("/reactions", h.RemoveIssueReaction) }) }) @@ -171,6 +173,8 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Route("/api/comments/{commentId}", func(r chi.Router) { r.Put("/", h.UpdateComment) r.Delete("/", h.DeleteComment) + r.Post("/reactions", h.AddReaction) + r.Delete("/reactions", h.RemoveReaction) }) // Agents diff --git a/server/internal/handler/activity.go b/server/internal/handler/activity.go index bcb05886..5430b78a 100644 --- a/server/internal/handler/activity.go +++ b/server/internal/handler/activity.go @@ -6,6 +6,7 @@ import ( "sort" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" db "github.com/multica-ai/multica/server/pkg/db/generated" ) @@ -24,10 +25,11 @@ type TimelineEntry struct { 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"` + Content *string `json:"content,omitempty"` + ParentID *string `json:"parent_id,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` + CommentType *string `json:"comment_type,omitempty"` + Reactions []ReactionResponse `json:"reactions,omitempty"` } // ListTimeline returns a merged, chronologically-sorted timeline of activities @@ -77,6 +79,13 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) { }) } + // Fetch reactions for all comments in one batch. + commentIDs := make([]pgtype.UUID, len(comments)) + for i, c := range comments { + commentIDs[i] = c.ID + } + grouped := h.groupReactions(r, commentIDs) + for _, c := range comments { content := c.Content commentType := c.Type @@ -91,6 +100,7 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) { ParentID: uuidToPtr(c.ParentID), CreatedAt: timestampToString(c.CreatedAt), UpdatedAt: &updatedAt, + Reactions: grouped[uuidToString(c.ID)], }) } diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 4d46c0ef..e8869072 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -13,18 +13,22 @@ import ( ) 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"` - ParentID *string `json:"parent_id"` - 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"` + Reactions []ReactionResponse `json:"reactions"` } -func commentToResponse(c db.Comment) CommentResponse { +func commentToResponse(c db.Comment, reactions []ReactionResponse) CommentResponse { + if reactions == nil { + reactions = []ReactionResponse{} + } return CommentResponse{ ID: uuidToString(c.ID), IssueID: uuidToString(c.IssueID), @@ -35,6 +39,7 @@ func commentToResponse(c db.Comment) CommentResponse { ParentID: uuidToPtr(c.ParentID), CreatedAt: timestampToString(c.CreatedAt), UpdatedAt: timestampToString(c.UpdatedAt), + Reactions: reactions, } } @@ -54,9 +59,15 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) { return } + commentIDs := make([]pgtype.UUID, len(comments)) + for i, c := range comments { + commentIDs[i] = c.ID + } + grouped := h.groupReactions(r, commentIDs) + resp := make([]CommentResponse, len(comments)) for i, c := range comments { - resp[i] = commentToResponse(c) + resp[i] = commentToResponse(c, grouped[uuidToString(c.ID)]) } writeJSON(w, http.StatusOK, resp) @@ -122,7 +133,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { return } - resp := commentToResponse(comment) + resp := commentToResponse(comment, nil) slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...) h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{ "comment": resp, @@ -197,7 +208,9 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) { return } - resp := commentToResponse(comment) + // Fetch reactions for the updated comment. + grouped := h.groupReactions(r, []pgtype.UUID{comment.ID}) + resp := commentToResponse(comment, grouped[uuidToString(comment.ID)]) slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...) h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp}) writeJSON(w, http.StatusOK, resp) diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 0e611999..bde6ca44 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -18,23 +18,24 @@ import ( // IssueResponse is the JSON response for an issue. type IssueResponse struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - Number int32 `json:"number"` - Identifier string `json:"identifier"` - Title string `json:"title"` - Description *string `json:"description"` - Status string `json:"status"` - Priority string `json:"priority"` - AssigneeType *string `json:"assignee_type"` - AssigneeID *string `json:"assignee_id"` - CreatorType string `json:"creator_type"` - CreatorID string `json:"creator_id"` - ParentIssueID *string `json:"parent_issue_id"` - Position float64 `json:"position"` - DueDate *string `json:"due_date"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Number int32 `json:"number"` + Identifier string `json:"identifier"` + Title string `json:"title"` + Description *string `json:"description"` + Status string `json:"status"` + Priority string `json:"priority"` + AssigneeType *string `json:"assignee_type"` + AssigneeID *string `json:"assignee_id"` + CreatorType string `json:"creator_type"` + CreatorID string `json:"creator_id"` + ParentIssueID *string `json:"parent_issue_id"` + Position float64 `json:"position"` + DueDate *string `json:"due_date"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Reactions []IssueReactionResponse `json:"reactions,omitempty"` } type agentTriggerSnapshot struct { @@ -130,7 +131,18 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) { return } prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID) - writeJSON(w, http.StatusOK, issueToResponse(issue, prefix)) + resp := issueToResponse(issue, prefix) + + // Fetch issue reactions. + reactions, err := h.Queries.ListIssueReactions(r.Context(), issue.ID) + if err == nil && len(reactions) > 0 { + resp.Reactions = make([]IssueReactionResponse, len(reactions)) + for i, rx := range reactions { + resp.Reactions[i] = issueReactionToResponse(rx) + } + } + + writeJSON(w, http.StatusOK, resp) } type CreateIssueRequest struct { diff --git a/server/internal/handler/issue_reaction.go b/server/internal/handler/issue_reaction.go new file mode 100644 index 00000000..b4e0a077 --- /dev/null +++ b/server/internal/handler/issue_reaction.go @@ -0,0 +1,131 @@ +package handler + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "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 IssueReactionResponse struct { + ID string `json:"id"` + IssueID string `json:"issue_id"` + ActorType string `json:"actor_type"` + ActorID string `json:"actor_id"` + Emoji string `json:"emoji"` + CreatedAt string `json:"created_at"` +} + +func issueReactionToResponse(r db.IssueReaction) IssueReactionResponse { + return IssueReactionResponse{ + ID: uuidToString(r.ID), + IssueID: uuidToString(r.IssueID), + ActorType: r.ActorType, + ActorID: uuidToString(r.ActorID), + Emoji: r.Emoji, + CreatedAt: timestampToString(r.CreatedAt), + } +} + +func (h *Handler) AddIssueReaction(w http.ResponseWriter, r *http.Request) { + issueID := chi.URLParam(r, "id") + issue, ok := h.loadIssueForUser(w, r, issueID) + if !ok { + return + } + + userID, ok := requireUserID(w, r) + if !ok { + return + } + + var req struct { + Emoji string `json:"emoji"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Emoji == "" { + writeError(w, http.StatusBadRequest, "emoji is required") + return + } + + workspaceID := uuidToString(issue.WorkspaceID) + actorType, actorID := h.resolveActor(r, userID, workspaceID) + + reaction, err := h.Queries.AddIssueReaction(r.Context(), db.AddIssueReactionParams{ + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + ActorType: actorType, + ActorID: parseUUID(actorID), + Emoji: req.Emoji, + }) + if err != nil { + slog.Warn("add issue reaction failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...) + writeError(w, http.StatusInternalServerError, "failed to add reaction") + return + } + + resp := issueReactionToResponse(reaction) + h.publish(protocol.EventIssueReactionAdded, workspaceID, actorType, actorID, map[string]any{ + "reaction": resp, + "issue_id": uuidToString(issue.ID), + "issue_title": issue.Title, + "issue_status": issue.Status, + "creator_type": issue.CreatorType, + "creator_id": uuidToString(issue.CreatorID), + }) + writeJSON(w, http.StatusCreated, resp) +} + +func (h *Handler) RemoveIssueReaction(w http.ResponseWriter, r *http.Request) { + issueID := chi.URLParam(r, "id") + issue, ok := h.loadIssueForUser(w, r, issueID) + if !ok { + return + } + + userID, ok := requireUserID(w, r) + if !ok { + return + } + + var req struct { + Emoji string `json:"emoji"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Emoji == "" { + writeError(w, http.StatusBadRequest, "emoji is required") + return + } + + workspaceID := uuidToString(issue.WorkspaceID) + actorType, actorID := h.resolveActor(r, userID, workspaceID) + + if err := h.Queries.RemoveIssueReaction(r.Context(), db.RemoveIssueReactionParams{ + IssueID: issue.ID, + ActorType: actorType, + ActorID: parseUUID(actorID), + Emoji: req.Emoji, + }); err != nil { + slog.Warn("remove issue reaction failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...) + writeError(w, http.StatusInternalServerError, "failed to remove reaction") + return + } + + h.publish(protocol.EventIssueReactionRemoved, workspaceID, actorType, actorID, map[string]any{ + "issue_id": uuidToString(issue.ID), + "emoji": req.Emoji, + "actor_type": actorType, + "actor_id": actorID, + }) + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/handler/reaction.go b/server/internal/handler/reaction.go new file mode 100644 index 00000000..c3665120 --- /dev/null +++ b/server/internal/handler/reaction.go @@ -0,0 +1,169 @@ +package handler + +import ( + "encoding/json" + "log/slog" + "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 ReactionResponse struct { + ID string `json:"id"` + CommentID string `json:"comment_id"` + ActorType string `json:"actor_type"` + ActorID string `json:"actor_id"` + Emoji string `json:"emoji"` + CreatedAt string `json:"created_at"` +} + +func reactionToResponse(r db.CommentReaction) ReactionResponse { + return ReactionResponse{ + ID: uuidToString(r.ID), + CommentID: uuidToString(r.CommentID), + ActorType: r.ActorType, + ActorID: uuidToString(r.ActorID), + Emoji: r.Emoji, + CreatedAt: timestampToString(r.CreatedAt), + } +} + +func (h *Handler) AddReaction(w http.ResponseWriter, r *http.Request) { + commentId := chi.URLParam(r, "commentId") + + userID, ok := requireUserID(w, r) + if !ok { + return + } + + workspaceID := resolveWorkspaceID(r) + comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{ + ID: parseUUID(commentId), + WorkspaceID: parseUUID(workspaceID), + }) + if err != nil { + writeError(w, http.StatusNotFound, "comment not found") + return + } + + var req struct { + Emoji string `json:"emoji"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Emoji == "" { + writeError(w, http.StatusBadRequest, "emoji is required") + return + } + + actorType, actorID := h.resolveActor(r, userID, workspaceID) + + reaction, err := h.Queries.AddReaction(r.Context(), db.AddReactionParams{ + CommentID: comment.ID, + WorkspaceID: parseUUID(workspaceID), + ActorType: actorType, + ActorID: parseUUID(actorID), + Emoji: req.Emoji, + }) + if err != nil { + slog.Warn("add reaction failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...) + writeError(w, http.StatusInternalServerError, "failed to add reaction") + return + } + + resp := reactionToResponse(reaction) + + // Look up issue title for inbox notifications. + issueID := uuidToString(comment.IssueID) + var issueTitle, issueStatus string + if issue, err := h.Queries.GetIssue(r.Context(), comment.IssueID); err == nil { + issueTitle = issue.Title + issueStatus = issue.Status + } + + h.publish(protocol.EventReactionAdded, workspaceID, actorType, actorID, map[string]any{ + "reaction": resp, + "issue_id": issueID, + "issue_title": issueTitle, + "issue_status": issueStatus, + "comment_author_type": comment.AuthorType, + "comment_author_id": uuidToString(comment.AuthorID), + }) + writeJSON(w, http.StatusCreated, resp) +} + +func (h *Handler) RemoveReaction(w http.ResponseWriter, r *http.Request) { + commentId := chi.URLParam(r, "commentId") + + userID, ok := requireUserID(w, r) + if !ok { + return + } + + workspaceID := resolveWorkspaceID(r) + comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{ + ID: parseUUID(commentId), + WorkspaceID: parseUUID(workspaceID), + }) + if err != nil { + writeError(w, http.StatusNotFound, "comment not found") + return + } + + var req struct { + Emoji string `json:"emoji"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Emoji == "" { + writeError(w, http.StatusBadRequest, "emoji is required") + return + } + + actorType, actorID := h.resolveActor(r, userID, workspaceID) + + if err := h.Queries.RemoveReaction(r.Context(), db.RemoveReactionParams{ + CommentID: comment.ID, + ActorType: actorType, + ActorID: parseUUID(actorID), + Emoji: req.Emoji, + }); err != nil { + slog.Warn("remove reaction failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...) + writeError(w, http.StatusInternalServerError, "failed to remove reaction") + return + } + + h.publish(protocol.EventReactionRemoved, workspaceID, actorType, actorID, map[string]any{ + "comment_id": commentId, + "issue_id": uuidToString(comment.IssueID), + "emoji": req.Emoji, + "actor_type": actorType, + "actor_id": actorID, + }) + w.WriteHeader(http.StatusNoContent) +} + +// groupReactions fetches reactions for the given comment IDs and groups them by comment_id. +func (h *Handler) groupReactions(r *http.Request, commentIDs []pgtype.UUID) map[string][]ReactionResponse { + if len(commentIDs) == 0 { + return nil + } + reactions, err := h.Queries.ListReactionsByCommentIDs(r.Context(), commentIDs) + if err != nil { + return nil + } + grouped := make(map[string][]ReactionResponse, len(commentIDs)) + for _, rx := range reactions { + cid := uuidToString(rx.CommentID) + grouped[cid] = append(grouped[cid], reactionToResponse(rx)) + } + return grouped +} diff --git a/server/migrations/026_comment_reactions.down.sql b/server/migrations/026_comment_reactions.down.sql new file mode 100644 index 00000000..652b9dd1 --- /dev/null +++ b/server/migrations/026_comment_reactions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS comment_reaction; diff --git a/server/migrations/026_comment_reactions.up.sql b/server/migrations/026_comment_reactions.up.sql new file mode 100644 index 00000000..4fd72a33 --- /dev/null +++ b/server/migrations/026_comment_reactions.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE comment_reaction ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + comment_id UUID NOT NULL REFERENCES comment(id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + actor_type TEXT NOT NULL CHECK (actor_type IN ('member', 'agent')), + actor_id UUID NOT NULL, + emoji TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (comment_id, actor_type, actor_id, emoji) +); + +CREATE INDEX idx_comment_reaction_comment_id ON comment_reaction(comment_id); diff --git a/server/migrations/027_issue_reactions.down.sql b/server/migrations/027_issue_reactions.down.sql new file mode 100644 index 00000000..f6193d11 --- /dev/null +++ b/server/migrations/027_issue_reactions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS issue_reaction; diff --git a/server/migrations/027_issue_reactions.up.sql b/server/migrations/027_issue_reactions.up.sql new file mode 100644 index 00000000..ead34e27 --- /dev/null +++ b/server/migrations/027_issue_reactions.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE issue_reaction ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issue_id UUID NOT NULL REFERENCES issue(id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + actor_type TEXT NOT NULL CHECK (actor_type IN ('member', 'agent')), + actor_id UUID NOT NULL, + emoji TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (issue_id, actor_type, actor_id, emoji) +); + +CREATE INDEX idx_issue_reaction_issue_id ON issue_reaction(issue_id); diff --git a/server/pkg/db/generated/issue_reaction.sql.go b/server/pkg/db/generated/issue_reaction.sql.go new file mode 100644 index 00000000..514af4e2 --- /dev/null +++ b/server/pkg/db/generated/issue_reaction.sql.go @@ -0,0 +1,104 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: issue_reaction.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const addIssueReaction = `-- name: AddIssueReaction :one +INSERT INTO issue_reaction (issue_id, workspace_id, actor_type, actor_id, emoji) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (issue_id, actor_type, actor_id, emoji) DO UPDATE SET created_at = issue_reaction.created_at +RETURNING id, issue_id, workspace_id, actor_type, actor_id, emoji, created_at +` + +type AddIssueReactionParams struct { + IssueID pgtype.UUID `json:"issue_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ActorType string `json:"actor_type"` + ActorID pgtype.UUID `json:"actor_id"` + Emoji string `json:"emoji"` +} + +func (q *Queries) AddIssueReaction(ctx context.Context, arg AddIssueReactionParams) (IssueReaction, error) { + row := q.db.QueryRow(ctx, addIssueReaction, + arg.IssueID, + arg.WorkspaceID, + arg.ActorType, + arg.ActorID, + arg.Emoji, + ) + var i IssueReaction + err := row.Scan( + &i.ID, + &i.IssueID, + &i.WorkspaceID, + &i.ActorType, + &i.ActorID, + &i.Emoji, + &i.CreatedAt, + ) + return i, err +} + +const listIssueReactions = `-- name: ListIssueReactions :many +SELECT id, issue_id, workspace_id, actor_type, actor_id, emoji, created_at FROM issue_reaction +WHERE issue_id = $1 +ORDER BY created_at ASC +` + +func (q *Queries) ListIssueReactions(ctx context.Context, issueID pgtype.UUID) ([]IssueReaction, error) { + rows, err := q.db.Query(ctx, listIssueReactions, issueID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []IssueReaction{} + for rows.Next() { + var i IssueReaction + if err := rows.Scan( + &i.ID, + &i.IssueID, + &i.WorkspaceID, + &i.ActorType, + &i.ActorID, + &i.Emoji, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const removeIssueReaction = `-- name: RemoveIssueReaction :exec +DELETE FROM issue_reaction +WHERE issue_id = $1 AND actor_type = $2 AND actor_id = $3 AND emoji = $4 +` + +type RemoveIssueReactionParams struct { + IssueID pgtype.UUID `json:"issue_id"` + ActorType string `json:"actor_type"` + ActorID pgtype.UUID `json:"actor_id"` + Emoji string `json:"emoji"` +} + +func (q *Queries) RemoveIssueReaction(ctx context.Context, arg RemoveIssueReactionParams) error { + _, err := q.db.Exec(ctx, removeIssueReaction, + arg.IssueID, + arg.ActorType, + arg.ActorID, + arg.Emoji, + ) + return err +} diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index c91bfe1d..2d0e386e 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -91,6 +91,16 @@ type Comment struct { WorkspaceID pgtype.UUID `json:"workspace_id"` } +type CommentReaction struct { + ID pgtype.UUID `json:"id"` + CommentID pgtype.UUID `json:"comment_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ActorType string `json:"actor_type"` + ActorID pgtype.UUID `json:"actor_id"` + Emoji string `json:"emoji"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type DaemonConnection struct { ID pgtype.UUID `json:"id"` AgentID pgtype.UUID `json:"agent_id"` @@ -173,6 +183,16 @@ type IssueLabel struct { Color string `json:"color"` } +type IssueReaction struct { + ID pgtype.UUID `json:"id"` + IssueID pgtype.UUID `json:"issue_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ActorType string `json:"actor_type"` + ActorID pgtype.UUID `json:"actor_id"` + Emoji string `json:"emoji"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type IssueSubscriber struct { IssueID pgtype.UUID `json:"issue_id"` UserType string `json:"user_type"` diff --git a/server/pkg/db/generated/reaction.sql.go b/server/pkg/db/generated/reaction.sql.go new file mode 100644 index 00000000..e4dde6ad --- /dev/null +++ b/server/pkg/db/generated/reaction.sql.go @@ -0,0 +1,104 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: reaction.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const addReaction = `-- name: AddReaction :one +INSERT INTO comment_reaction (comment_id, workspace_id, actor_type, actor_id, emoji) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (comment_id, actor_type, actor_id, emoji) DO UPDATE SET created_at = comment_reaction.created_at +RETURNING id, comment_id, workspace_id, actor_type, actor_id, emoji, created_at +` + +type AddReactionParams struct { + CommentID pgtype.UUID `json:"comment_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ActorType string `json:"actor_type"` + ActorID pgtype.UUID `json:"actor_id"` + Emoji string `json:"emoji"` +} + +func (q *Queries) AddReaction(ctx context.Context, arg AddReactionParams) (CommentReaction, error) { + row := q.db.QueryRow(ctx, addReaction, + arg.CommentID, + arg.WorkspaceID, + arg.ActorType, + arg.ActorID, + arg.Emoji, + ) + var i CommentReaction + err := row.Scan( + &i.ID, + &i.CommentID, + &i.WorkspaceID, + &i.ActorType, + &i.ActorID, + &i.Emoji, + &i.CreatedAt, + ) + return i, err +} + +const listReactionsByCommentIDs = `-- name: ListReactionsByCommentIDs :many +SELECT id, comment_id, workspace_id, actor_type, actor_id, emoji, created_at FROM comment_reaction +WHERE comment_id = ANY($1::uuid[]) +ORDER BY created_at ASC +` + +func (q *Queries) ListReactionsByCommentIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]CommentReaction, error) { + rows, err := q.db.Query(ctx, listReactionsByCommentIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []CommentReaction{} + for rows.Next() { + var i CommentReaction + if err := rows.Scan( + &i.ID, + &i.CommentID, + &i.WorkspaceID, + &i.ActorType, + &i.ActorID, + &i.Emoji, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const removeReaction = `-- name: RemoveReaction :exec +DELETE FROM comment_reaction +WHERE comment_id = $1 AND actor_type = $2 AND actor_id = $3 AND emoji = $4 +` + +type RemoveReactionParams struct { + CommentID pgtype.UUID `json:"comment_id"` + ActorType string `json:"actor_type"` + ActorID pgtype.UUID `json:"actor_id"` + Emoji string `json:"emoji"` +} + +func (q *Queries) RemoveReaction(ctx context.Context, arg RemoveReactionParams) error { + _, err := q.db.Exec(ctx, removeReaction, + arg.CommentID, + arg.ActorType, + arg.ActorID, + arg.Emoji, + ) + return err +} diff --git a/server/pkg/db/queries/issue_reaction.sql b/server/pkg/db/queries/issue_reaction.sql new file mode 100644 index 00000000..00e7c45b --- /dev/null +++ b/server/pkg/db/queries/issue_reaction.sql @@ -0,0 +1,14 @@ +-- name: AddIssueReaction :one +INSERT INTO issue_reaction (issue_id, workspace_id, actor_type, actor_id, emoji) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (issue_id, actor_type, actor_id, emoji) DO UPDATE SET created_at = issue_reaction.created_at +RETURNING *; + +-- name: RemoveIssueReaction :exec +DELETE FROM issue_reaction +WHERE issue_id = $1 AND actor_type = $2 AND actor_id = $3 AND emoji = $4; + +-- name: ListIssueReactions :many +SELECT * FROM issue_reaction +WHERE issue_id = $1 +ORDER BY created_at ASC; diff --git a/server/pkg/db/queries/reaction.sql b/server/pkg/db/queries/reaction.sql new file mode 100644 index 00000000..10afbade --- /dev/null +++ b/server/pkg/db/queries/reaction.sql @@ -0,0 +1,14 @@ +-- name: AddReaction :one +INSERT INTO comment_reaction (comment_id, workspace_id, actor_type, actor_id, emoji) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (comment_id, actor_type, actor_id, emoji) DO UPDATE SET created_at = comment_reaction.created_at +RETURNING *; + +-- name: RemoveReaction :exec +DELETE FROM comment_reaction +WHERE comment_id = $1 AND actor_type = $2 AND actor_id = $3 AND emoji = $4; + +-- name: ListReactionsByCommentIDs :many +SELECT * FROM comment_reaction +WHERE comment_id = ANY($1::uuid[]) +ORDER BY created_at ASC; diff --git a/server/pkg/protocol/events.go b/server/pkg/protocol/events.go index 3edd5bdb..bca1b598 100644 --- a/server/pkg/protocol/events.go +++ b/server/pkg/protocol/events.go @@ -8,9 +8,13 @@ const ( EventIssueDeleted = "issue:deleted" // Comment events - EventCommentCreated = "comment:created" - EventCommentUpdated = "comment:updated" - EventCommentDeleted = "comment:deleted" + EventCommentCreated = "comment:created" + EventCommentUpdated = "comment:updated" + EventCommentDeleted = "comment:deleted" + EventReactionAdded = "reaction:added" + EventReactionRemoved = "reaction:removed" + EventIssueReactionAdded = "issue_reaction:added" + EventIssueReactionRemoved = "issue_reaction:removed" // Agent events EventAgentStatus = "agent:status"