feat: inbox actor tracking, issue detail extraction, UI polish
- Add actor_type/actor_id to inbox items for proper attribution - Extract issue detail into features/issues/components/issue-detail.tsx - Inbox page and store updates for actor-based notifications - Sidebar, layout, and actor-avatar refinements Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
586a4916d1
commit
bc39abc6ed
17 changed files with 203 additions and 85 deletions
|
|
@ -20,7 +20,6 @@ import { WorkspaceAvatar } from "@/features/workspace";
|
|||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
|
|
@ -43,11 +42,14 @@ import { useWorkspaceStore } from "@/features/workspace";
|
|||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
|
||||
const navItems = [
|
||||
const primaryNav = [
|
||||
{ href: "/inbox", label: "Inbox", icon: Inbox },
|
||||
{ href: "/issues", label: "Issues", icon: ListTodo },
|
||||
];
|
||||
|
||||
const workspaceNav = [
|
||||
{ href: "/agents", label: "Agents", icon: Bot },
|
||||
{ href: "/skills", label: "Skills", icon: Sparkles },
|
||||
{ href: "/issues", label: "Issues", icon: ListTodo },
|
||||
{ href: "/knowledge-base", label: "Knowledge Base", icon: BookOpen },
|
||||
];
|
||||
|
||||
|
|
@ -180,7 +182,7 @@ export function AppSidebar() {
|
|||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-0.5">
|
||||
{navItems.map((item) => {
|
||||
{primaryNav.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
|
|
@ -203,28 +205,29 @@ export function AppSidebar() {
|
|||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
{/* User */}
|
||||
<SidebarFooter>
|
||||
{user && (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="sm">
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs leading-none font-medium">
|
||||
{user.name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</div>
|
||||
<span className="truncate">{user.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</SidebarFooter>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-0.5">
|
||||
{workspaceNav.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
render={<Link href={item.href} />}
|
||||
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { IssueDetail } from "@/features/issues/components";
|
||||
import { IssueDetail, StatusIcon } from "@/features/issues/components";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -64,14 +63,10 @@ function InboxListItem({
|
|||
item,
|
||||
isSelected,
|
||||
onClick,
|
||||
getActorName,
|
||||
getActorInitials,
|
||||
}: {
|
||||
item: InboxItem;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
getActorName: (type: string, id: string) => string;
|
||||
getActorInitials: (type: string, id: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
|
|
@ -81,30 +76,35 @@ function InboxListItem({
|
|||
}`}
|
||||
>
|
||||
<ActorAvatar
|
||||
actorType={item.recipient_type}
|
||||
actorId={item.recipient_id}
|
||||
actorType={item.actor_type ?? item.recipient_type}
|
||||
actorId={item.actor_id ?? item.recipient_id}
|
||||
size={28}
|
||||
getName={getActorName}
|
||||
getInitials={getActorInitials}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={`truncate text-sm ${!item.read ? "font-medium" : "text-muted-foreground"}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{item.issue_status && (
|
||||
<StatusIcon status={item.issue_status} className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{!item.read && (
|
||||
<span className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{typeLabels[item.type] ?? item.type}
|
||||
</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{timeAgo(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{typeLabels[item.type] ?? item.type}
|
||||
</p>
|
||||
</div>
|
||||
{!item.read && (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -118,7 +118,6 @@ export default function InboxPage() {
|
|||
|
||||
const storeItems = useInboxStore((s) => s.items);
|
||||
const loading = useInboxStore((s) => s.loading);
|
||||
const { getActorName, getActorInitials } = useActorName();
|
||||
|
||||
// Sort: severity first, then newest first
|
||||
const items = useMemo(() => {
|
||||
|
|
@ -253,7 +252,7 @@ export default function InboxPage() {
|
|||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuItem onClick={handleMarkAllRead}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Mark all as read
|
||||
|
|
@ -288,8 +287,6 @@ export default function InboxPage() {
|
|||
item={item}
|
||||
isSelected={item.id === selectedId}
|
||||
onClick={() => handleSelect(item)}
|
||||
getActorName={getActorName}
|
||||
getActorInitials={getActorInitials}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -297,7 +294,7 @@ export default function InboxPage() {
|
|||
</div>
|
||||
|
||||
{/* Right column — detail */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{selected?.issue_id ? (
|
||||
<IssueDetail
|
||||
issueId={selected.issue_id}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export default function DashboardLayout({
|
|||
if (!user || !workspace) return null;
|
||||
|
||||
return (
|
||||
<SidebarProvider className="max-h-svh">
|
||||
<SidebarProvider className="h-svh">
|
||||
<AppSidebar />
|
||||
<SidebarInset className="overflow-hidden">{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default function RootLayout({
|
|||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans h-full overflow-hidden", geist.variable, geistMono.variable)}
|
||||
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
|
||||
>
|
||||
<body className="h-full overflow-hidden">
|
||||
<ThemeProvider>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { Bot } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
|
||||
interface ActorAvatarProps {
|
||||
actorType: string;
|
||||
|
|
@ -18,8 +21,12 @@ function ActorAvatar({
|
|||
getInitials,
|
||||
className,
|
||||
}: ActorAvatarProps) {
|
||||
const name = getName?.(actorType, actorId);
|
||||
const initials = getInitials?.(actorType, actorId);
|
||||
const actorNameHook = useActorName();
|
||||
const resolveName = getName ?? actorNameHook.getActorName;
|
||||
const resolveInitials = getInitials ?? actorNameHook.getActorInitials;
|
||||
|
||||
const name = resolveName(actorType, actorId);
|
||||
const initials = resolveInitials(actorType, actorId);
|
||||
const isAgent = actorType === "agent";
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { InboxItem } from "@multica/types";
|
||||
import type { InboxItem, IssueStatus } from "@multica/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
|
|
@ -18,6 +18,7 @@ interface InboxState {
|
|||
markAllRead: () => void;
|
||||
archiveAll: () => void;
|
||||
archiveAllRead: () => void;
|
||||
updateIssueStatus: (issueId: string, status: IssueStatus) => void;
|
||||
unreadCount: () => number;
|
||||
}
|
||||
|
||||
|
|
@ -67,5 +68,11 @@ export const useInboxStore = create<InboxState>((set, get) => ({
|
|||
i.read && !i.archived ? { ...i, archived: true } : i
|
||||
),
|
||||
})),
|
||||
updateIssueStatus: (issueId, status) =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.issue_id === issueId ? { ...i, issue_status: status } : i
|
||||
),
|
||||
})),
|
||||
unreadCount: () => get().items.filter((i) => !i.read && !i.archived).length,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -322,7 +322,7 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
|
|||
const id = issueId;
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { getActorName, getActorInitials } = useActorName();
|
||||
const { getActorName } = useActorName();
|
||||
const [issue, setIssue] = useState<Issue | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -621,8 +621,6 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
|
|||
actorType={comment.author_type}
|
||||
actorId={comment.author_id}
|
||||
size={28}
|
||||
getName={getActorName}
|
||||
getInitials={getActorInitials}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{getActorName(comment.author_type, comment.author_id)}
|
||||
|
|
@ -737,8 +735,6 @@ export function IssueDetail({ issueId, showBreadcrumb, onDelete }: IssueDetailPr
|
|||
actorType={issue.creator_type}
|
||||
actorId={issue.creator_id}
|
||||
size={18}
|
||||
getName={getActorName}
|
||||
getInitials={getActorInitials}
|
||||
/>
|
||||
<span>{getActorName(issue.creator_type, issue.creator_id)}</span>
|
||||
</PropRow>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
ws.on("issue:updated", (p) => {
|
||||
const { issue } = p as IssueUpdatedPayload;
|
||||
useIssueStore.getState().updateIssue(issue.id, issue);
|
||||
useInboxStore.getState().updateIssueStatus(issue.id, issue.status);
|
||||
}),
|
||||
ws.on("issue:deleted", (p) => {
|
||||
const { issue_id } = p as IssueDeletedPayload;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { IssueStatus } from "./issue";
|
||||
|
||||
export type InboxSeverity = "action_required" | "attention" | "info";
|
||||
|
||||
export type InboxItemType =
|
||||
|
|
@ -13,11 +15,14 @@ export interface InboxItem {
|
|||
workspace_id: string;
|
||||
recipient_type: "member" | "agent";
|
||||
recipient_id: string;
|
||||
actor_type: "member" | "agent" | null;
|
||||
actor_id: string | null;
|
||||
type: InboxItemType;
|
||||
severity: InboxSeverity;
|
||||
issue_id: string | null;
|
||||
title: string;
|
||||
body: string | null;
|
||||
issue_status: IssueStatus | null;
|
||||
read: boolean;
|
||||
archived: boolean;
|
||||
created_at: string;
|
||||
|
|
|
|||
|
|
@ -38,18 +38,23 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
|
|||
IssueID: parseUUID(issue.ID),
|
||||
Title: "New issue assigned: " + issue.Title,
|
||||
Body: util.PtrToText(issue.Description),
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("inbox item creation failed", "event", "issue:created", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := inboxItemToResponse(item)
|
||||
resp["issue_status"] = issue.Status
|
||||
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventInboxNew,
|
||||
WorkspaceID: e.WorkspaceID,
|
||||
ActorType: e.ActorType,
|
||||
ActorID: e.ActorID,
|
||||
Payload: map[string]any{"item": inboxItemToResponse(item)},
|
||||
Payload: map[string]any{"item": resp},
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -84,14 +89,18 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
|
|||
Severity: "info",
|
||||
IssueID: parseUUID(issue.ID),
|
||||
Title: "Unassigned from: " + issue.Title,
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
})
|
||||
if err == nil {
|
||||
oldResp := inboxItemToResponse(oldItem)
|
||||
oldResp["issue_status"] = issue.Status
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventInboxNew,
|
||||
WorkspaceID: e.WorkspaceID,
|
||||
ActorType: e.ActorType,
|
||||
ActorID: actorID,
|
||||
Payload: map[string]any{"item": inboxItemToResponse(oldItem)},
|
||||
Payload: map[string]any{"item": oldResp},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -106,14 +115,18 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
|
|||
Severity: "action_required",
|
||||
IssueID: parseUUID(issue.ID),
|
||||
Title: "Assigned to you: " + issue.Title,
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
})
|
||||
if err == nil {
|
||||
newResp := inboxItemToResponse(newItem)
|
||||
newResp["issue_status"] = issue.Status
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventInboxNew,
|
||||
WorkspaceID: e.WorkspaceID,
|
||||
ActorType: e.ActorType,
|
||||
ActorID: actorID,
|
||||
Payload: map[string]any{"item": inboxItemToResponse(newItem)},
|
||||
Payload: map[string]any{"item": newResp},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -130,14 +143,18 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
|
|||
Severity: "info",
|
||||
IssueID: parseUUID(issue.ID),
|
||||
Title: issue.Title + " moved to " + issue.Status,
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
})
|
||||
if err == nil {
|
||||
aResp := inboxItemToResponse(aItem)
|
||||
aResp["issue_status"] = issue.Status
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventInboxNew,
|
||||
WorkspaceID: e.WorkspaceID,
|
||||
ActorType: e.ActorType,
|
||||
ActorID: actorID,
|
||||
Payload: map[string]any{"item": inboxItemToResponse(aItem)},
|
||||
Payload: map[string]any{"item": aResp},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -155,14 +172,18 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
|
|||
Severity: "info",
|
||||
IssueID: parseUUID(issue.ID),
|
||||
Title: "Status changed: " + issue.Title,
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
})
|
||||
if err == nil {
|
||||
cResp := inboxItemToResponse(cItem)
|
||||
cResp["issue_status"] = issue.Status
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventInboxNew,
|
||||
WorkspaceID: e.WorkspaceID,
|
||||
ActorType: e.ActorType,
|
||||
ActorID: actorID,
|
||||
Payload: map[string]any{"item": inboxItemToResponse(cItem)},
|
||||
Payload: map[string]any{"item": cResp},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -183,6 +204,7 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
|
|||
issueTitle, _ := payload["issue_title"].(string)
|
||||
issueAssigneeType, _ := payload["issue_assignee_type"].(*string)
|
||||
issueAssigneeID, _ := payload["issue_assignee_id"].(*string)
|
||||
issueStatus, _ := payload["issue_status"].(string)
|
||||
|
||||
// Only notify if assignee is a member and is not the commenter
|
||||
if issueAssigneeType == nil || issueAssigneeID == nil {
|
||||
|
|
@ -201,18 +223,23 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
|
|||
IssueID: parseUUID(comment.IssueID),
|
||||
Title: "New comment on: " + issueTitle,
|
||||
Body: util.StrToText(comment.Content),
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("inbox item creation failed", "event", "comment:created", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
commentResp := inboxItemToResponse(item)
|
||||
commentResp["issue_status"] = issueStatus
|
||||
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventInboxNew,
|
||||
WorkspaceID: e.WorkspaceID,
|
||||
ActorType: e.ActorType,
|
||||
ActorID: e.ActorID,
|
||||
Payload: map[string]any{"item": inboxItemToResponse(item)},
|
||||
Payload: map[string]any{"item": commentResp},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -233,5 +260,7 @@ func inboxItemToResponse(item db.InboxItem) map[string]any {
|
|||
"read": item.Read,
|
||||
"archived": item.Archived,
|
||||
"created_at": util.TimestampToString(item.CreatedAt),
|
||||
"actor_type": util.TextToPtr(item.ActorType),
|
||||
"actor_id": util.UUIDToPtr(item.ActorID),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,10 +103,11 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
|||
resp := commentToResponse(comment)
|
||||
slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...)
|
||||
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{
|
||||
"comment": resp,
|
||||
"issue_title": issue.Title,
|
||||
"issue_assignee_type": textToPtr(issue.AssigneeType),
|
||||
"issue_assignee_id": uuidToPtr(issue.AssigneeID),
|
||||
"comment": resp,
|
||||
"issue_title": issue.Title,
|
||||
"issue_assignee_type": textToPtr(issue.AssigneeType),
|
||||
"issue_assignee_id": uuidToPtr(issue.AssigneeID),
|
||||
"issue_status": issue.Status,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ type InboxItemResponse struct {
|
|||
Read bool `json:"read"`
|
||||
Archived bool `json:"archived"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
IssueStatus *string `json:"issue_status"`
|
||||
ActorType *string `json:"actor_type"`
|
||||
ActorID *string `json:"actor_id"`
|
||||
}
|
||||
|
||||
func inboxToResponse(i db.InboxItem) InboxItemResponse {
|
||||
|
|
@ -40,6 +43,28 @@ func inboxToResponse(i db.InboxItem) InboxItemResponse {
|
|||
Read: i.Read,
|
||||
Archived: i.Archived,
|
||||
CreatedAt: timestampToString(i.CreatedAt),
|
||||
ActorType: textToPtr(i.ActorType),
|
||||
ActorID: uuidToPtr(i.ActorID),
|
||||
}
|
||||
}
|
||||
|
||||
func inboxRowToResponse(r db.ListInboxItemsRow) InboxItemResponse {
|
||||
return InboxItemResponse{
|
||||
ID: uuidToString(r.ID),
|
||||
WorkspaceID: uuidToString(r.WorkspaceID),
|
||||
RecipientType: r.RecipientType,
|
||||
RecipientID: uuidToString(r.RecipientID),
|
||||
Type: r.Type,
|
||||
Severity: r.Severity,
|
||||
IssueID: uuidToPtr(r.IssueID),
|
||||
Title: r.Title,
|
||||
Body: textToPtr(r.Body),
|
||||
Read: r.Read,
|
||||
Archived: r.Archived,
|
||||
CreatedAt: timestampToString(r.CreatedAt),
|
||||
IssueStatus: textToPtr(r.IssueStatus),
|
||||
ActorType: textToPtr(r.ActorType),
|
||||
ActorID: uuidToPtr(r.ActorID),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +100,7 @@ func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
resp := make([]InboxItemResponse, len(items))
|
||||
for i, item := range items {
|
||||
resp[i] = inboxToResponse(item)
|
||||
resp[i] = inboxRowToResponse(item)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
|
|
|||
2
server/migrations/009_inbox_actor.down.sql
Normal file
2
server/migrations/009_inbox_actor.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE inbox_item DROP COLUMN IF EXISTS actor_type;
|
||||
ALTER TABLE inbox_item DROP COLUMN IF EXISTS actor_id;
|
||||
2
server/migrations/009_inbox_actor.up.sql
Normal file
2
server/migrations/009_inbox_actor.up.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE inbox_item ADD COLUMN actor_type TEXT;
|
||||
ALTER TABLE inbox_item ADD COLUMN actor_id UUID;
|
||||
|
|
@ -54,7 +54,7 @@ func (q *Queries) ArchiveCompletedInbox(ctx context.Context, recipientID pgtype.
|
|||
const archiveInboxItem = `-- name: ArchiveInboxItem :one
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id
|
||||
`
|
||||
|
||||
func (q *Queries) ArchiveInboxItem(ctx context.Context, id pgtype.UUID) (InboxItem, error) {
|
||||
|
|
@ -73,6 +73,8 @@ func (q *Queries) ArchiveInboxItem(ctx context.Context, id pgtype.UUID) (InboxIt
|
|||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
&i.ActorType,
|
||||
&i.ActorID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -97,9 +99,10 @@ func (q *Queries) CountUnreadInbox(ctx context.Context, arg CountUnreadInboxPara
|
|||
const createInboxItem = `-- name: CreateInboxItem :one
|
||||
INSERT INTO inbox_item (
|
||||
workspace_id, recipient_type, recipient_id,
|
||||
type, severity, issue_id, title, body
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at
|
||||
type, severity, issue_id, title, body,
|
||||
actor_type, actor_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id
|
||||
`
|
||||
|
||||
type CreateInboxItemParams struct {
|
||||
|
|
@ -111,6 +114,8 @@ type CreateInboxItemParams struct {
|
|||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Title string `json:"title"`
|
||||
Body pgtype.Text `json:"body"`
|
||||
ActorType pgtype.Text `json:"actor_type"`
|
||||
ActorID pgtype.UUID `json:"actor_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateInboxItem(ctx context.Context, arg CreateInboxItemParams) (InboxItem, error) {
|
||||
|
|
@ -123,6 +128,8 @@ func (q *Queries) CreateInboxItem(ctx context.Context, arg CreateInboxItemParams
|
|||
arg.IssueID,
|
||||
arg.Title,
|
||||
arg.Body,
|
||||
arg.ActorType,
|
||||
arg.ActorID,
|
||||
)
|
||||
var i InboxItem
|
||||
err := row.Scan(
|
||||
|
|
@ -138,12 +145,14 @@ func (q *Queries) CreateInboxItem(ctx context.Context, arg CreateInboxItemParams
|
|||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
&i.ActorType,
|
||||
&i.ActorID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getInboxItem = `-- name: GetInboxItem :one
|
||||
SELECT id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at FROM inbox_item
|
||||
SELECT id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id FROM inbox_item
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
|
|
@ -163,14 +172,19 @@ func (q *Queries) GetInboxItem(ctx context.Context, id pgtype.UUID) (InboxItem,
|
|||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
&i.ActorType,
|
||||
&i.ActorID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listInboxItems = `-- name: ListInboxItems :many
|
||||
SELECT id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at FROM inbox_item
|
||||
WHERE recipient_type = $1 AND recipient_id = $2 AND archived = false
|
||||
ORDER BY created_at DESC
|
||||
SELECT i.id, i.workspace_id, i.recipient_type, i.recipient_id, i.type, i.severity, i.issue_id, i.title, i.body, i.read, i.archived, i.created_at, i.actor_type, i.actor_id,
|
||||
iss.status as issue_status
|
||||
FROM inbox_item i
|
||||
LEFT JOIN issue iss ON iss.id = i.issue_id
|
||||
WHERE i.recipient_type = $1 AND i.recipient_id = $2 AND i.archived = false
|
||||
ORDER BY i.created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
|
|
@ -181,7 +195,25 @@ type ListInboxItemsParams struct {
|
|||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams) ([]InboxItem, error) {
|
||||
type ListInboxItemsRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RecipientType string `json:"recipient_type"`
|
||||
RecipientID pgtype.UUID `json:"recipient_id"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Title string `json:"title"`
|
||||
Body pgtype.Text `json:"body"`
|
||||
Read bool `json:"read"`
|
||||
Archived bool `json:"archived"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
ActorType pgtype.Text `json:"actor_type"`
|
||||
ActorID pgtype.UUID `json:"actor_id"`
|
||||
IssueStatus pgtype.Text `json:"issue_status"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams) ([]ListInboxItemsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listInboxItems,
|
||||
arg.RecipientType,
|
||||
arg.RecipientID,
|
||||
|
|
@ -192,9 +224,9 @@ func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams)
|
|||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []InboxItem{}
|
||||
items := []ListInboxItemsRow{}
|
||||
for rows.Next() {
|
||||
var i InboxItem
|
||||
var i ListInboxItemsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
|
|
@ -208,6 +240,9 @@ func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams)
|
|||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
&i.ActorType,
|
||||
&i.ActorID,
|
||||
&i.IssueStatus,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -235,7 +270,7 @@ func (q *Queries) MarkAllInboxRead(ctx context.Context, recipientID pgtype.UUID)
|
|||
const markInboxRead = `-- name: MarkInboxRead :one
|
||||
UPDATE inbox_item SET read = true
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at, actor_type, actor_id
|
||||
`
|
||||
|
||||
func (q *Queries) MarkInboxRead(ctx context.Context, id pgtype.UUID) (InboxItem, error) {
|
||||
|
|
@ -254,6 +289,8 @@ func (q *Queries) MarkInboxRead(ctx context.Context, id pgtype.UUID) (InboxItem,
|
|||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
&i.ActorType,
|
||||
&i.ActorID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ type InboxItem struct {
|
|||
Read bool `json:"read"`
|
||||
Archived bool `json:"archived"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
ActorType pgtype.Text `json:"actor_type"`
|
||||
ActorID pgtype.UUID `json:"actor_id"`
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
-- name: ListInboxItems :many
|
||||
SELECT * FROM inbox_item
|
||||
WHERE recipient_type = $1 AND recipient_id = $2 AND archived = false
|
||||
ORDER BY created_at DESC
|
||||
SELECT i.*,
|
||||
iss.status as issue_status
|
||||
FROM inbox_item i
|
||||
LEFT JOIN issue iss ON iss.id = i.issue_id
|
||||
WHERE i.recipient_type = $1 AND i.recipient_id = $2 AND i.archived = false
|
||||
ORDER BY i.created_at DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: GetInboxItem :one
|
||||
|
|
@ -11,8 +14,9 @@ WHERE id = $1;
|
|||
-- name: CreateInboxItem :one
|
||||
INSERT INTO inbox_item (
|
||||
workspace_id, recipient_type, recipient_id,
|
||||
type, severity, issue_id, title, body
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
type, severity, issue_id, title, body,
|
||||
actor_type, actor_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *;
|
||||
|
||||
-- name: MarkInboxRead :one
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue