From 0491350f1b09be1037c79a220a8abad9e3b000d1 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Mon, 30 Mar 2026 22:22:04 +0800 Subject: [PATCH 1/3] feat(security): add agent output redaction and private agent assignment enforcement - Add redact package to detect and mask secrets (AWS keys, private keys, API tokens, bearer tokens, credentials, home paths) in agent output before posting as comments in TaskService - Enforce agent visibility on issue assignment: private agents can only be assigned by their owner or workspace admins - Add visibility picker (workspace/private) to CreateAgentDialog, default to private - Grey out unassignable private agents in the assignee picker with lock icon indicator --- apps/web/app/(dashboard)/agents/page.tsx | 41 ++++++ .../components/pickers/assignee-picker.tsx | 60 +++++--- .../components/pickers/property-picker.tsx | 5 +- server/internal/handler/agent.go | 2 +- server/internal/handler/issue.go | 51 +++++++ server/internal/service/task.go | 5 +- server/pkg/agent/claude_test.go | 1 + server/pkg/redact/redact.go | 71 +++++++++ server/pkg/redact/redact_test.go | 139 ++++++++++++++++++ 9 files changed, 351 insertions(+), 24 deletions(-) create mode 100644 server/pkg/redact/redact.go create mode 100644 server/pkg/redact/redact_test.go diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 7fb11efd..902cbcf4 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -25,10 +25,13 @@ import { MoreHorizontal, Play, ChevronDown, + Globe, + Lock, } from "lucide-react"; import type { Agent, AgentStatus, + AgentVisibility, AgentTool, AgentTrigger, AgentTriggerType, @@ -126,6 +129,7 @@ function CreateAgentDialog({ const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [selectedRuntimeId, setSelectedRuntimeId] = useState(runtimes[0]?.id ?? ""); + const [visibility, setVisibility] = useState("private"); const [creating, setCreating] = useState(false); const [runtimeOpen, setRuntimeOpen] = useState(false); @@ -145,6 +149,7 @@ function CreateAgentDialog({ name: name.trim(), description: description.trim(), runtime_id: selectedRuntime.id, + visibility, triggers: [{ id: generateId(), type: "on_assign", enabled: true, config: {} }], }); onClose(); @@ -189,6 +194,42 @@ function CreateAgentDialog({ /> +
+ +
+ + +
+
+
diff --git a/apps/web/features/issues/components/pickers/assignee-picker.tsx b/apps/web/features/issues/components/pickers/assignee-picker.tsx index a41ba9cc..5ffd0d2c 100644 --- a/apps/web/features/issues/components/pickers/assignee-picker.tsx +++ b/apps/web/features/issues/components/pickers/assignee-picker.tsx @@ -1,8 +1,9 @@ "use client"; import { useState } from "react"; -import { Bot, UserMinus } from "lucide-react"; -import type { IssueAssigneeType, UpdateIssueRequest } from "@/shared/types"; +import { Bot, Lock, UserMinus } from "lucide-react"; +import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types"; +import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; import { PropertyPicker, @@ -11,6 +12,13 @@ import { PickerEmpty, } from "./property-picker"; +function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean { + if (agent.visibility !== "private") return true; + if (agent.owner_id === userId) return true; + if (memberRole === "owner" || memberRole === "admin") return true; + return false; +} + export function AssigneePicker({ assigneeType, assigneeId, @@ -24,10 +32,14 @@ export function AssigneePicker({ }) { const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); + const user = useAuthStore((s) => s.user); const members = useWorkspaceStore((s) => s.members); const agents = useWorkspaceStore((s) => s.agents); const { getActorName, getActorInitials } = useActorName(); + const currentMember = members.find((m) => m.user_id === user?.id); + const memberRole = currentMember?.role; + const query = filter.toLowerCase(); const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(query), @@ -117,24 +129,32 @@ export function AssigneePicker({ {/* Agents */} {filteredAgents.length > 0 && ( - {filteredAgents.map((a) => ( - { - onUpdate({ - assignee_type: "agent", - assignee_id: a.id, - }); - setOpen(false); - }} - > -
- -
- {a.name} -
- ))} + {filteredAgents.map((a) => { + const allowed = canAssignAgent(a, user?.id, memberRole); + return ( + { + if (!allowed) return; + onUpdate({ + assignee_type: "agent", + assignee_id: a.id, + }); + setOpen(false); + }} + > +
+ +
+ {a.name} + {a.visibility === "private" && ( + + )} +
+ ); + })}
)} diff --git a/apps/web/features/issues/components/pickers/property-picker.tsx b/apps/web/features/issues/components/pickers/property-picker.tsx index 2aa2048d..1329fa4d 100644 --- a/apps/web/features/issues/components/pickers/property-picker.tsx +++ b/apps/web/features/issues/components/pickers/property-picker.tsx @@ -79,11 +79,13 @@ export function PropertyPicker({ export function PickerItem({ selected, + disabled, onClick, hoverClassName, children, }: { selected: boolean; + disabled?: boolean; onClick: () => void; hoverClassName?: string; children: React.ReactNode; @@ -91,8 +93,9 @@ export function PickerItem({ return ( + } + /> + + {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"