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:
Naiyuan Qing 2026-03-26 12:36:12 +08:00
parent 586a4916d1
commit bc39abc6ed
17 changed files with 203 additions and 85 deletions

View file

@ -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>
);
}

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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 (

View file

@ -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,
}));

View file

@ -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>

View file

@ -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;

View file

@ -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;

View file

@ -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),
}
}

View file

@ -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)

View file

@ -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)

View 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;

View file

@ -0,0 +1,2 @@
ALTER TABLE inbox_item ADD COLUMN actor_type TEXT;
ALTER TABLE inbox_item ADD COLUMN actor_id UUID;

View file

@ -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
}

View file

@ -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 {

View file

@ -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