Compare commits

..

2 Commits

Author SHA1 Message Date
Jiayuan
9bc8dcc053 feat(issues): add Relations section in sidebar for managing issue dependencies
Users can now create, view, and remove issue dependencies directly from
the issue detail sidebar. The Relations section shows existing links with
type labels (blocks, blocked by, related to) and a searchable popover
for adding new relations. Activity entries appear on both issues when
relations change.
2026-04-04 00:05:15 +08:00
Jiayuan
473ec17c65 feat(activity): add issue relation activities to timeline
When issue dependencies are created or removed, both the source and
target issues now receive activity entries in their timeline. This
enables bi-directional visibility — e.g., issue A's timeline shows
"added relation: blocks MUL-456" while issue B shows "added relation:
blocked by MUL-123".

Changes:
- Add CRUD SQL queries and handler endpoints for issue_dependency
- Add issue_dependency:created/removed event types
- Add activity listener that records activities on both issues
- Render relation activities in the frontend with Link2 icon
2026-04-03 23:51:36 +08:00
38 changed files with 615 additions and 1750 deletions

View File

@@ -15,7 +15,6 @@ import {
BookOpenText,
SquarePen,
CircleUser,
FolderKanban,
} from "lucide-react";
import { WorkspaceAvatar } from "@/features/workspace";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
@@ -50,7 +49,6 @@ const primaryNav = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
{ href: "/my-issues", label: "My Issues", icon: CircleUser },
{ href: "/issues", label: "Issues", icon: ListTodo },
{ href: "/projects", label: "Projects", icon: FolderKanban },
];
const workspaceNav = [

View File

@@ -80,7 +80,6 @@ const stableStoreIssues = vi.hoisted(() => [
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 0,
due_date: "2026-06-01T00:00:00Z",
created_at: "2026-01-15T00:00:00Z",
@@ -186,6 +185,9 @@ vi.mock("@/shared/api", () => ({
getActiveTaskForIssue: vi.fn().mockResolvedValue({ task: null }),
listTasksByIssue: vi.fn().mockResolvedValue([]),
listTaskMessages: vi.fn().mockResolvedValue([]),
listDependencies: vi.fn().mockResolvedValue([]),
createDependency: vi.fn().mockResolvedValue({}),
deleteDependency: vi.fn().mockResolvedValue(undefined),
},
}));
@@ -203,7 +205,6 @@ const mockIssue: Issue = {
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 0,
due_date: "2026-06-01T00:00:00Z",
created_at: "2026-01-15T00:00:00Z",

View File

@@ -220,7 +220,6 @@ vi.mock("@dnd-kit/utilities", () => ({
const issueDefaults = {
parent_issue_id: null,
project_id: null,
position: 0,
};

View File

@@ -1,9 +0,0 @@
"use client";
import { use } from "react";
import { ProjectDetailPage } from "@/features/projects/components/project-detail-page";
export default function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
return <ProjectDetailPage projectId={id} />;
}

View File

@@ -1,7 +0,0 @@
"use client";
import { ProjectsPage } from "@/features/projects/components/projects-page";
export default function Page() {
return <ProjectsPage />;
}

View File

@@ -13,6 +13,7 @@ import {
Link2,
MoreHorizontal,
PanelRight,
Plus,
Trash2,
UserMinus,
Users,
@@ -57,14 +58,13 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
import { ActorAvatar } from "@/components/common/actor-avatar";
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
import type { UpdateIssueRequest, IssueStatus, IssuePriority, IssueDependency, IssueDependencyType, TimelineEntry } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components";
import { CommentCard } from "./comment-card";
import { CommentInput } from "./comment-input";
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
import { api } from "@/shared/api";
import { ProjectPicker } from "@/features/projects/components/project-picker";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
@@ -126,11 +126,42 @@ function formatActivity(
return "completed the task";
case "task_failed":
return "task failed";
case "issue_relation_added": {
const relType = details.relation_type ?? "";
const identifier = details.related_issue_identifier ?? "?";
const label = relationTypeLabel(relType);
return `added relation: ${label} ${identifier}`;
}
case "issue_relation_removed": {
const relType = details.relation_type ?? "";
const identifier = details.related_issue_identifier ?? "?";
const label = relationTypeLabel(relType);
return `removed relation: ${label} ${identifier}`;
}
default:
return entry.action ?? "";
}
}
function relationTypeLabel(type: string): string {
switch (type) {
case "blocks":
return "blocks";
case "blocked_by":
return "blocked by";
case "related":
return "related to";
default:
return type;
}
}
function inverseRelType(type: string): string {
if (type === "blocks") return "blocked_by";
if (type === "blocked_by") return "blocks";
return type;
}
// ---------------------------------------------------------------------------
// Property row
@@ -195,7 +226,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const [deleting, setDeleting] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [propertiesOpen, setPropertiesOpen] = useState(true);
const [relationsOpen, setRelationsOpen] = useState(true);
const [detailsOpen, setDetailsOpen] = useState(true);
const [dependencies, setDependencies] = useState<IssueDependency[]>([]);
const [addRelOpen, setAddRelOpen] = useState(false);
const [relSearch, setRelSearch] = useState("");
const [relType, setRelType] = useState<IssueDependencyType>("related");
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showScrollBottom, setShowScrollBottom] = useState(false);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
@@ -241,6 +277,32 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const loading = issueLoading;
// Fetch issue dependencies
const fetchDependencies = useCallback(() => {
api.listDependencies(id).then(setDependencies).catch(() => {});
}, [id]);
useEffect(() => { fetchDependencies(); }, [fetchDependencies]);
const handleAddDependency = useCallback(async (targetIssueId: string, type: IssueDependencyType) => {
try {
await api.createDependency(id, targetIssueId, type);
fetchDependencies();
setAddRelOpen(false);
setRelSearch("");
} catch {
toast.error("Failed to add relation");
}
}, [id, fetchDependencies]);
const handleRemoveDependency = useCallback(async (depId: string) => {
try {
await api.deleteDependency(id, depId);
fetchDependencies();
} catch {
toast.error("Failed to remove relation");
}
}, [id, fetchDependencies]);
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
useEffect(() => {
if (!highlightCommentId || timeline.length === 0) return;
@@ -872,6 +934,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const isStatusChange = entry.action === "status_changed";
const isPriorityChange = entry.action === "priority_changed";
const isDueDateChange = entry.action === "due_date_changed";
const isRelationChange = entry.action === "issue_relation_added" || entry.action === "issue_relation_removed";
let leadIcon: React.ReactNode;
if (isStatusChange && details.to) {
@@ -880,6 +943,8 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
} else if (isDueDateChange) {
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else if (isRelationChange) {
leadIcon = <Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else {
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
}
@@ -1019,18 +1084,105 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
onUpdate={handleUpdateField}
/>
</PropRow>
{/* Project */}
<PropRow label="Project">
<ProjectPicker
projectId={issue.project_id}
onUpdate={handleUpdateField}
align="start"
/>
</PropRow>
</div>}
</div>
{/* Relations section */}
<div>
<div className="flex items-center mb-2">
<button
className={`flex flex-1 items-center gap-1 text-xs font-medium transition-colors ${relationsOpen ? "" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setRelationsOpen(!relationsOpen)}
>
<ChevronRight className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${relationsOpen ? "rotate-90" : ""}`} />
Relations
{dependencies.length > 0 && <span className="text-muted-foreground ml-1">({dependencies.length})</span>}
</button>
<Popover open={addRelOpen} onOpenChange={setAddRelOpen}>
<PopoverTrigger
render={
<button className="p-0.5 rounded hover:bg-accent/50 text-muted-foreground hover:text-foreground transition-colors">
<Plus className="h-3.5 w-3.5" />
</button>
}
/>
<PopoverContent align="end" className="w-72 p-0">
<div className="p-2 border-b">
<div className="flex gap-1 mb-2">
{(["related", "blocks", "blocked_by"] as const).map((t) => (
<button
key={t}
className={`px-2 py-0.5 text-xs rounded-full transition-colors ${relType === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"}`}
onClick={() => setRelType(t)}
>
{relationTypeLabel(t)}
</button>
))}
</div>
</div>
<Command>
<CommandInput placeholder="Search issues..." value={relSearch} onValueChange={setRelSearch} />
<CommandList>
<CommandEmpty>No issues found</CommandEmpty>
<CommandGroup>
{allIssues
.filter((i) => i.id !== id)
.filter((i) => !dependencies.some(
(d) => (d.issue_id === id ? d.depends_on_issue_id : d.issue_id) === i.id
))
.slice(0, 20)
.map((i) => (
<CommandItem
key={i.id}
value={`${i.identifier} ${i.title}`}
onSelect={() => handleAddDependency(i.id, relType)}
>
<span className="text-muted-foreground shrink-0 mr-1.5">{i.identifier}</span>
<span className="truncate">{i.title}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{relationsOpen && dependencies.length > 0 && (
<div className="space-y-1 pl-2">
{dependencies.map((dep) => {
// Determine which side is the "other" issue
const isSource = dep.issue_id === id;
const otherIdentifier = isSource ? dep.depends_on_issue_identifier : dep.issue_identifier;
const otherTitle = isSource ? dep.depends_on_issue_title : dep.issue_title;
const otherIssueId = isSource ? dep.depends_on_issue_id : dep.issue_id;
// Show the relation type from this issue's perspective
const displayType = isSource ? dep.type : inverseRelType(dep.type);
return (
<div key={dep.id} className="group flex items-center gap-1.5 text-xs rounded-md px-2 -mx-2 min-h-7 hover:bg-accent/50 transition-colors">
<Link2 className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="shrink-0 text-muted-foreground">{relationTypeLabel(displayType)}</span>
<Link href={`/issues/${otherIssueId}`} className="flex items-center gap-1 min-w-0 hover:underline">
<span className="shrink-0 text-muted-foreground">{otherIdentifier}</span>
<span className="truncate">{otherTitle}</span>
</Link>
<button
className="ml-auto shrink-0 opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-accent transition-all text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveDependency(dep.id)}
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
</div>
)}
{relationsOpen && dependencies.length === 0 && (
<div className="pl-2 text-xs text-muted-foreground">No relations</div>
)}
</div>
{/* Details section */}
<div>
<button

View File

@@ -25,7 +25,6 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
creator_type: "member",
creator_id: "u-1",
parent_issue_id: null,
project_id: null,
position: 0,
due_date: null,
created_at: "2025-01-01T00:00:00Z",

View File

@@ -1,139 +0,0 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ProjectStatus } from "@/shared/types";
import { useProjectStore } from "@/features/projects";
import { PROJECT_STATUSES, PROJECT_STATUS_CONFIG, PROJECT_COLORS } from "@/features/projects/config/status";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface CreateProjectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState<ProjectStatus>("backlog");
const [color, setColor] = useState(PROJECT_COLORS[0]);
const [loading, setLoading] = useState(false);
const createProject = useProjectStore((s) => s.createProject);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
try {
await createProject({
name: name.trim(),
description: description.trim() || undefined,
status,
color,
});
toast.success("Project created");
onOpenChange(false);
setName("");
setDescription("");
setStatus("backlog");
setColor(PROJECT_COLORS[0]);
} catch {
toast.error("Failed to create project");
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>New Project</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="project-name">Name</Label>
<Input
id="project-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="project-desc">Description</Label>
<Textarea
id="project-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as ProjectStatus)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROJECT_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{PROJECT_STATUS_CONFIG[s].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex gap-1.5 flex-wrap">
{PROJECT_COLORS.map((c) => (
<button
key={c}
type="button"
className={cn(
"h-6 w-6 rounded-full transition-all",
color === c ? "ring-2 ring-offset-2 ring-primary" : "hover:scale-110",
)}
style={{ backgroundColor: c }}
onClick={() => setColor(c)}
/>
))}
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim() || loading}>
{loading ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,233 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ChevronLeft,
Check,
MoreHorizontal,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
import type { UpdateProjectRequest } from "@/shared/types";
import { useProjectStore } from "@/features/projects";
import { useIssueStore } from "@/features/issues";
import { useActorName } from "@/features/workspace";
import { ProjectStatusBadge } from "./project-status-badge";
import { ProjectProgressBar } from "./project-progress-bar";
import { PROJECT_STATUSES, PROJECT_STATUS_CONFIG } from "@/features/projects/config/status";
import { StatusIcon } from "@/features/issues/components";
import { STATUS_CONFIG } from "@/features/issues/config";
import { api } from "@/shared/api";
export function ProjectDetailPage({ projectId }: { projectId: string }) {
const router = useRouter();
const project = useProjectStore((s) => s.projects.find((p) => p.id === projectId)) ?? null;
const updateProjectApi = useProjectStore((s) => s.updateProjectApi);
const deleteProject = useProjectStore((s) => s.deleteProject);
const allIssues = useIssueStore((s) => s.issues);
const { getActorName } = useActorName();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(!project);
// If project isn't in the store yet, fetch it
useEffect(() => {
if (project) {
setLoading(false);
return;
}
api.getProject(projectId).then((p) => {
useProjectStore.getState().addProject(p);
setLoading(false);
}).catch(() => {
setLoading(false);
});
}, [project, projectId]);
const projectIssues = allIssues.filter((i) => i.project_id === projectId);
const handleUpdateField = useCallback(
(updates: UpdateProjectRequest) => {
if (!project) return;
updateProjectApi(project.id, updates);
},
[project, updateProjectApi],
);
const handleDelete = async () => {
await deleteProject(projectId);
router.push("/projects");
};
if (loading) {
return (
<div className="p-6 space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (!project) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
Project not found
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-3 border-b px-6 py-3">
<Link
href="/projects"
className="inline-flex items-center justify-center h-7 w-7 rounded-md hover:bg-accent transition-colors"
>
<ChevronLeft className="h-4 w-4" />
</Link>
<div
className="h-3 w-3 rounded-full shrink-0"
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
<h1 className="text-lg font-semibold truncate flex-1">{project.name}</h1>
{/* Status dropdown */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="sm" className="gap-1.5">
<ProjectStatusBadge status={project.status} />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-44">
{PROJECT_STATUSES.map((s) => (
<DropdownMenuItem key={s} onClick={() => handleUpdateField({ status: s })}>
<span className={PROJECT_STATUS_CONFIG[s].color}>
{PROJECT_STATUS_CONFIG[s].label}
</span>
{s === project.status && <Check className="ml-auto h-3.5 w-3.5" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* More menu */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Progress */}
<div className="max-w-sm">
<ProjectProgressBar progress={project.progress} />
</div>
{/* Description */}
{project.description && (
<p className="text-sm text-muted-foreground">{project.description}</p>
)}
{/* Meta */}
<div className="flex gap-6 text-xs text-muted-foreground">
{project.lead_type && project.lead_id && (
<span>Lead: {getActorName(project.lead_type, project.lead_id)}</span>
)}
{project.start_date && (
<span>Start: {new Date(project.start_date).toLocaleDateString()}</span>
)}
{project.target_date && (
<span>Target: {new Date(project.target_date).toLocaleDateString()}</span>
)}
</div>
{/* Issues list */}
<div>
<h2 className="text-sm font-medium mb-3">
Issues ({projectIssues.length})
</h2>
{projectIssues.length === 0 ? (
<p className="text-xs text-muted-foreground">
No issues in this project yet. Assign issues from the issue detail page.
</p>
) : (
<div className="space-y-1">
{projectIssues.map((issue) => (
<Link
key={issue.id}
href={`/issues/${issue.id}`}
className="flex items-center gap-3 rounded-md px-3 py-2 hover:bg-accent/50 transition-colors text-sm"
>
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs text-muted-foreground shrink-0">
{issue.identifier}
</span>
<span className="truncate flex-1">{issue.title}</span>
<span className="text-xs text-muted-foreground shrink-0">
{STATUS_CONFIG[issue.status].label}
</span>
</Link>
))}
</div>
)}
</div>
</div>
{/* Delete dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete project?</AlertDialogTitle>
<AlertDialogDescription>
This will delete the project. Issues in this project will not be deleted,
but they will no longer be associated with any project.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -1,87 +0,0 @@
"use client";
import { useState } from "react";
import { Check, FolderKanban, X } from "lucide-react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "@/components/ui/command";
import { useProjectStore } from "@/features/projects";
interface ProjectPickerProps {
projectId: string | null;
onUpdate: (updates: { project_id?: string | null }) => void;
align?: "start" | "end";
}
export function ProjectPicker({ projectId, onUpdate, align = "start" }: ProjectPickerProps) {
const projects = useProjectStore((s) => s.projects);
const [open, setOpen] = useState(false);
const current = projects.find((p) => p.id === projectId);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden text-xs">
{current ? (
<>
<div
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: current.color ?? "#6366f1" }}
/>
<span className="truncate">{current.name}</span>
</>
) : (
<span className="text-muted-foreground">None</span>
)}
</PopoverTrigger>
<PopoverContent className="w-52 p-0" align={align}>
<Command>
<CommandInput placeholder="Search projects..." />
<CommandList>
<CommandEmpty>No projects found.</CommandEmpty>
<CommandGroup>
{projectId && (
<CommandItem
onSelect={() => {
onUpdate({ project_id: null });
setOpen(false);
}}
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Remove from project</span>
</CommandItem>
)}
{projects.map((p) => (
<CommandItem
key={p.id}
onSelect={() => {
onUpdate({ project_id: p.id });
setOpen(false);
}}
>
<div
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: p.color ?? "#6366f1" }}
/>
<span className="truncate">{p.name}</span>
{p.id === projectId && (
<Check className="ml-auto h-3.5 w-3.5 shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,30 +0,0 @@
"use client";
import type { ProjectProgress } from "@/shared/types";
import { cn } from "@/lib/utils";
export function ProjectProgressBar({
progress,
className,
}: {
progress?: ProjectProgress;
className?: string;
}) {
const pct = progress?.percent ?? 0;
const total = progress?.total ?? 0;
const completed = progress?.completed ?? 0;
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="h-1.5 flex-1 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{completed}/{total}
</span>
</div>
);
}

View File

@@ -1,27 +0,0 @@
"use client";
import type { ProjectStatus } from "@/shared/types";
import { PROJECT_STATUS_CONFIG } from "@/features/projects/config/status";
import { cn } from "@/lib/utils";
export function ProjectStatusBadge({
status,
className,
}: {
status: ProjectStatus;
className?: string;
}) {
const config = PROJECT_STATUS_CONFIG[status];
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
config.bg,
config.color,
className,
)}
>
{config.label}
</span>
);
}

View File

@@ -1,103 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useProjectStore } from "@/features/projects";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { ProjectStatusBadge } from "./project-status-badge";
import { ProjectProgressBar } from "./project-progress-bar";
import { CreateProjectDialog } from "./create-project-dialog";
import { Skeleton } from "@/components/ui/skeleton";
export function ProjectsPage() {
const projects = useProjectStore((s) => s.projects);
const loading = useProjectStore((s) => s.loading);
const [createOpen, setCreateOpen] = useState(false);
const { getActorName } = useActorName();
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between border-b px-6 py-3">
<h1 className="text-lg font-semibold">Projects</h1>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
New Project
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<p className="text-sm">No projects yet</p>
<Button variant="outline" size="sm" className="mt-3" onClick={() => setCreateOpen(true)}>
Create your first project
</Button>
</div>
) : (
<div className="space-y-2">
{projects.map((project) => (
<Link
key={project.id}
href={`/projects/${project.id}`}
className="flex items-center gap-4 rounded-lg border p-4 hover:bg-accent/50 transition-colors"
>
{/* Color dot */}
<div
className="h-3 w-3 rounded-full shrink-0"
style={{ backgroundColor: project.color ?? "#6366f1" }}
/>
{/* Name + description */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{project.name}</span>
<ProjectStatusBadge status={project.status} />
</div>
{project.description && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{project.description}
</p>
)}
</div>
{/* Lead */}
{project.lead_type && project.lead_id && (
<span className="text-xs text-muted-foreground shrink-0">
{getActorName(project.lead_type, project.lead_id)}
</span>
)}
{/* Progress */}
<div className="w-32 shrink-0">
<ProjectProgressBar progress={project.progress} />
</div>
{/* Target date */}
{project.target_date && (
<span className="text-xs text-muted-foreground shrink-0">
{new Date(project.target_date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</span>
)}
</Link>
))}
</div>
)}
</div>
<CreateProjectDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View File

@@ -1,33 +0,0 @@
import type { ProjectStatus } from "@/shared/types";
export const PROJECT_STATUSES: ProjectStatus[] = [
"backlog",
"planned",
"in_progress",
"completed",
"cancelled",
];
export const PROJECT_STATUS_CONFIG: Record<
ProjectStatus,
{ label: string; color: string; bg: string }
> = {
backlog: { label: "Backlog", color: "text-muted-foreground", bg: "bg-muted" },
planned: { label: "Planned", color: "text-blue-600", bg: "bg-blue-50 dark:bg-blue-950" },
in_progress: { label: "In Progress", color: "text-yellow-600", bg: "bg-yellow-50 dark:bg-yellow-950" },
completed: { label: "Completed", color: "text-green-600", bg: "bg-green-50 dark:bg-green-950" },
cancelled: { label: "Cancelled", color: "text-muted-foreground", bg: "bg-muted" },
};
export const PROJECT_COLORS = [
"#6366f1",
"#8b5cf6",
"#ec4899",
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#14b8a6",
"#06b6d4",
"#3b82f6",
];

View File

@@ -1 +0,0 @@
export { useProjectStore } from "./store";

View File

@@ -1,80 +0,0 @@
"use client";
import { create } from "zustand";
import type { Project, CreateProjectRequest, UpdateProjectRequest } from "@/shared/types";
import { toast } from "sonner";
import { api } from "@/shared/api";
interface ProjectState {
projects: Project[];
loading: boolean;
fetch: () => Promise<void>;
addProject: (project: Project) => void;
updateProject: (id: string, updates: Partial<Project>) => void;
removeProject: (id: string) => void;
createProject: (data: CreateProjectRequest) => Promise<Project>;
updateProjectApi: (id: string, data: UpdateProjectRequest) => Promise<void>;
deleteProject: (id: string) => Promise<void>;
}
export const useProjectStore = create<ProjectState>((set, get) => ({
projects: [],
loading: true,
fetch: async () => {
const isInitialLoad = get().projects.length === 0;
if (isInitialLoad) set({ loading: true });
try {
const res = await api.listProjects();
set({ projects: res.projects, loading: false });
} catch {
toast.error("Failed to load projects");
if (isInitialLoad) set({ loading: false });
}
},
addProject: (project) =>
set((s) => ({
projects: s.projects.some((p) => p.id === project.id)
? s.projects
: [...s.projects, project],
})),
updateProject: (id, updates) =>
set((s) => ({
projects: s.projects.map((p) => (p.id === id ? { ...p, ...updates } : p)),
})),
removeProject: (id) =>
set((s) => ({ projects: s.projects.filter((p) => p.id !== id) })),
createProject: async (data) => {
const project = await api.createProject(data);
get().addProject(project);
return project;
},
updateProjectApi: async (id, data) => {
const prev = get().projects.find((p) => p.id === id);
get().updateProject(id, data);
try {
const updated = await api.updateProject(id, data);
get().updateProject(id, updated);
} catch {
if (prev) get().updateProject(id, prev);
toast.error("Failed to update project");
}
},
deleteProject: async (id) => {
const prev = get().projects;
get().removeProject(id);
try {
await api.deleteProject(id);
toast.success("Project deleted");
} catch {
set({ projects: prev });
toast.error("Failed to delete project");
}
},
}));

View File

@@ -6,7 +6,6 @@ import { toast } from "sonner";
import { useIssueStore } from "@/features/issues";
import { useInboxStore } from "@/features/inbox";
import { useWorkspaceStore } from "@/features/workspace";
import { useProjectStore } from "@/features/projects";
import { useAuthStore } from "@/features/auth";
import { createLogger } from "@/shared/logger";
import { api } from "@/shared/api";
@@ -61,7 +60,6 @@ export function useRealtimeSync(ws: WSClient | null) {
});
},
skill: () => void useWorkspaceStore.getState().refreshSkills(),
project: () => void useProjectStore.getState().fetch(),
};
const timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -175,7 +173,6 @@ export function useRealtimeSync(ws: WSClient | null) {
useWorkspaceStore.getState().refreshAgents(),
useWorkspaceStore.getState().refreshMembers(),
useWorkspaceStore.getState().refreshSkills(),
useProjectStore.getState().fetch(),
]);
} catch (e) {
logger.error("reconnect refetch failed", e);

View File

@@ -5,7 +5,6 @@ import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types";
import { useIssueStore } from "@/features/issues";
import { useInboxStore } from "@/features/inbox";
import { useRuntimeStore } from "@/features/runtimes";
import { useProjectStore } from "@/features/projects";
import { toast } from "sonner";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";
@@ -91,7 +90,6 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
api.listSkills().catch(() => [] as Skill[]),
useIssueStore.getState().fetch().catch(() => {}),
useInboxStore.getState().fetch().catch(() => {}),
useProjectStore.getState().fetch().catch(() => {}),
]);
logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length);
set({ members: nextMembers, agents: nextAgents, skills: nextSkills });
@@ -116,7 +114,6 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
useIssueStore.getState().setIssues([]);
useInboxStore.getState().setItems([]);
useRuntimeStore.getState().setRuntimes([]);
useProjectStore.setState({ projects: [], loading: true });
set({ workspace: ws, members: [], agents: [], skills: [] });
await hydrateWorkspace(workspaces, ws.id);

View File

@@ -35,10 +35,8 @@ import type {
TimelineEntry,
TaskMessagePayload,
Attachment,
Project,
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
IssueDependency,
IssueDependencyType,
} from "@/shared/types";
import { type Logger, noopLogger } from "@/shared/logger";
@@ -230,6 +228,22 @@ export class ApiClient {
return this.fetch(`/api/issues/${issueId}/timeline`);
}
// Issue dependencies
async listDependencies(issueId: string): Promise<IssueDependency[]> {
return this.fetch(`/api/issues/${issueId}/dependencies`);
}
async createDependency(issueId: string, dependsOnIssueId: string, type: IssueDependencyType): Promise<IssueDependency> {
return this.fetch(`/api/issues/${issueId}/dependencies`, {
method: "POST",
body: JSON.stringify({ depends_on_issue_id: dependsOnIssueId, type }),
});
}
async deleteDependency(issueId: string, depId: string): Promise<void> {
await this.fetch(`/api/issues/${issueId}/dependencies/${depId}`, { method: "DELETE" });
}
async updateComment(commentId: string, content: string): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}`, {
method: "PUT",
@@ -583,33 +597,4 @@ export class ApiClient {
async deleteAttachment(id: string): Promise<void> {
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
}
// Projects
async listProjects(params?: { status?: string }): Promise<ListProjectsResponse> {
const search = new URLSearchParams();
if (params?.status) search.set("status", params.status);
return this.fetch(`/api/projects?${search}`);
}
async getProject(id: string): Promise<Project> {
return this.fetch(`/api/projects/${id}`);
}
async createProject(data: CreateProjectRequest): Promise<Project> {
return this.fetch("/api/projects", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
return this.fetch(`/api/projects/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteProject(id: string): Promise<void> {
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
}
}

View File

@@ -10,7 +10,6 @@ export interface CreateIssueRequest {
assignee_type?: IssueAssigneeType;
assignee_id?: string;
parent_issue_id?: string;
project_id?: string;
due_date?: string;
}
@@ -21,7 +20,6 @@ export interface UpdateIssueRequest {
priority?: IssuePriority;
assignee_type?: IssueAssigneeType | null;
assignee_id?: string | null;
project_id?: string | null;
position?: number;
due_date?: string | null;
}

View File

@@ -1,4 +1,4 @@
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueReaction } from "./issue";
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueReaction, IssueDependency, IssueDependencyType } from "./issue";
export type {
Agent,
AgentStatus,
@@ -32,12 +32,3 @@ export type { IssueSubscriber } from "./subscriber";
export type * from "./events";
export type * from "./api";
export type { Attachment } from "./attachment";
export type {
Project,
ProjectStatus,
ProjectLeadType,
ProjectProgress,
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
} from "./project";

View File

@@ -20,6 +20,19 @@ export interface IssueReaction {
created_at: string;
}
export type IssueDependencyType = "blocks" | "blocked_by" | "related";
export interface IssueDependency {
id: string;
issue_id: string;
depends_on_issue_id: string;
type: IssueDependencyType;
issue_identifier: string;
issue_title: string;
depends_on_issue_identifier: string;
depends_on_issue_title: string;
}
export interface Issue {
id: string;
workspace_id: string;
@@ -34,7 +47,6 @@ export interface Issue {
creator_type: IssueAssigneeType;
creator_id: string;
parent_issue_id: string | null;
project_id: string | null;
position: number;
due_date: string | null;
reactions?: IssueReaction[];

View File

@@ -1,62 +0,0 @@
export type ProjectStatus =
| "backlog"
| "planned"
| "in_progress"
| "completed"
| "cancelled";
export type ProjectLeadType = "member" | "agent";
export interface ProjectProgress {
total: number;
completed: number;
percent: number;
}
export interface Project {
id: string;
workspace_id: string;
name: string;
description: string | null;
status: ProjectStatus;
icon: string | null;
color: string | null;
lead_type: ProjectLeadType | null;
lead_id: string | null;
start_date: string | null;
target_date: string | null;
sort_order: number;
progress?: ProjectProgress;
created_at: string;
updated_at: string;
}
export interface CreateProjectRequest {
name: string;
description?: string;
status?: ProjectStatus;
icon?: string;
color?: string;
lead_type?: ProjectLeadType;
lead_id?: string;
start_date?: string;
target_date?: string;
}
export interface UpdateProjectRequest {
name?: string;
description?: string;
status?: ProjectStatus;
icon?: string;
color?: string;
lead_type?: ProjectLeadType | null;
lead_id?: string | null;
start_date?: string | null;
target_date?: string | null;
sort_order?: number;
}
export interface ListProjectsResponse {
projects: Project[];
total: number;
}

View File

@@ -220,6 +220,16 @@ func registerActivityListeners(bus *events.Bus, queries *db.Queries) {
bus.Subscribe(protocol.EventTaskFailed, func(e events.Event) {
handleTaskActivity(ctx, bus, queries, e, "task_failed")
})
// issue_dependency:created — record "issue_relation_added" on both issues
bus.Subscribe(protocol.EventIssueDependencyCreated, func(e events.Event) {
handleDependencyActivity(ctx, bus, queries, e, "issue_relation_added")
})
// issue_dependency:removed — record "issue_relation_removed" on both issues
bus.Subscribe(protocol.EventIssueDependencyRemoved, func(e events.Event) {
handleDependencyActivity(ctx, bus, queries, e, "issue_relation_removed")
})
}
// handleTaskActivity records an activity for task:completed or task:failed events.
@@ -259,6 +269,81 @@ func handleTaskActivity(ctx context.Context, bus *events.Bus, queries *db.Querie
publishActivityEvent(bus, e, activity)
}
// handleDependencyActivity records activities on both issues when a dependency
// is created or removed. Each issue gets an activity entry with details about
// the related issue (identifier, title, relation type).
func handleDependencyActivity(ctx context.Context, bus *events.Bus, queries *db.Queries, e events.Event, action string) {
payload, ok := e.Payload.(map[string]any)
if !ok {
return
}
dep, _ := payload["dependency"].(db.IssueDependency)
issue, _ := payload["issue"].(db.Issue)
targetIssue, _ := payload["target_issue"].(db.Issue)
issueIdentifier, _ := payload["issue_identifier"].(string)
targetIdentifier, _ := payload["target_issue_identifier"].(string)
issueID := util.UUIDToString(dep.IssueID)
targetIssueID := util.UUIDToString(dep.DependsOnIssueID)
// Activity on the source issue: "added relation: blocks MUL-456"
srcDetails, _ := json.Marshal(map[string]string{
"related_issue_id": targetIssueID,
"related_issue_identifier": targetIdentifier,
"related_issue_title": targetIssue.Title,
"relation_type": dep.Type,
})
srcActivity, err := queries.CreateActivity(ctx, db.CreateActivityParams{
WorkspaceID: issue.WorkspaceID,
IssueID: dep.IssueID,
ActorType: util.StrToText(e.ActorType),
ActorID: parseUUID(e.ActorID),
Action: action,
Details: srcDetails,
})
if err != nil {
slog.Error("activity: failed to record dependency activity on source issue",
"issue_id", issueID, "action", action, "error", err)
} else {
publishActivityEvent(bus, e, srcActivity)
}
// Activity on the target issue: "added relation: blocked_by MUL-123"
inverseType := inverseRelationType(dep.Type)
targetDetails, _ := json.Marshal(map[string]string{
"related_issue_id": issueID,
"related_issue_identifier": issueIdentifier,
"related_issue_title": issue.Title,
"relation_type": inverseType,
})
targetActivity, err := queries.CreateActivity(ctx, db.CreateActivityParams{
WorkspaceID: targetIssue.WorkspaceID,
IssueID: dep.DependsOnIssueID,
ActorType: util.StrToText(e.ActorType),
ActorID: parseUUID(e.ActorID),
Action: action,
Details: targetDetails,
})
if err != nil {
slog.Error("activity: failed to record dependency activity on target issue",
"issue_id", targetIssueID, "action", action, "error", err)
} else {
publishActivityEvent(bus, e, targetActivity)
}
}
// inverseRelationType returns the inverse of a dependency relation type.
func inverseRelationType(t string) string {
switch t {
case "blocks":
return "blocked_by"
case "blocked_by":
return "blocks"
default:
return t // "related" is symmetric
}
}
// publishActivityEvent sends an activity:created event for WS broadcasting.
// Payload matches frontend ActivityCreatedPayload: { issue_id, entry: TimelineEntry }
func publishActivityEvent(bus *events.Bus, original events.Event, activity db.ActivityLog) {

View File

@@ -153,17 +153,6 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Group(func(r chi.Router) {
r.Use(middleware.RequireWorkspaceMember(queries))
// Projects
r.Route("/api/projects", func(r chi.Router) {
r.Get("/", h.ListProjects)
r.Post("/", h.CreateProject)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", h.GetProject)
r.Put("/", h.UpdateProject)
r.Delete("/", h.DeleteProject)
})
})
// Issues
r.Route("/api/issues", func(r chi.Router) {
r.Get("/", h.ListIssues)
@@ -186,6 +175,9 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Post("/reactions", h.AddIssueReaction)
r.Delete("/reactions", h.RemoveIssueReaction)
r.Get("/attachments", h.ListAttachments)
r.Get("/dependencies", h.ListIssueDependencies)
r.Post("/dependencies", h.CreateIssueDependency)
r.Delete("/dependencies/{depId}", h.DeleteIssueDependency)
})
})

View File

@@ -31,7 +31,6 @@ type IssueResponse struct {
CreatorType string `json:"creator_type"`
CreatorID string `json:"creator_id"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
Position float64 `json:"position"`
DueDate *string `json:"due_date"`
CreatedAt string `json:"created_at"`
@@ -73,7 +72,6 @@ func issueToResponse(i db.Issue, issuePrefix string) IssueResponse {
CreatorType: i.CreatorType,
CreatorID: uuidToString(i.CreatorID),
ParentIssueID: uuidToPtr(i.ParentIssueID),
ProjectID: uuidToPtr(i.ProjectID),
Position: i.Position,
DueDate: timestampToPtr(i.DueDate),
CreatedAt: timestampToString(i.CreatedAt),
@@ -112,10 +110,6 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
if a := r.URL.Query().Get("assignee_id"); a != "" {
assigneeFilter = parseUUID(a)
}
var projectFilter pgtype.UUID
if p := r.URL.Query().Get("project_id"); p != "" {
projectFilter = parseUUID(p)
}
issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{
WorkspaceID: parseUUID(workspaceID),
@@ -124,7 +118,6 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Status: statusFilter,
Priority: priorityFilter,
AssigneeID: assigneeFilter,
ProjectID: projectFilter,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list issues")
@@ -184,7 +177,6 @@ type CreateIssueRequest struct {
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
DueDate *string `json:"due_date"`
}
@@ -239,11 +231,6 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
parentIssueID = parseUUID(*req.ParentIssueID)
}
var projectID pgtype.UUID
if req.ProjectID != nil {
projectID = parseUUID(*req.ProjectID)
}
var dueDate pgtype.Timestamptz
if req.DueDate != nil && *req.DueDate != "" {
t, err := time.Parse(time.RFC3339, *req.DueDate)
@@ -288,7 +275,6 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
Position: 0,
DueDate: dueDate,
Number: issueNumber,
ProjectID: projectID,
})
if err != nil {
slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
@@ -323,7 +309,6 @@ type UpdateIssueRequest struct {
Priority *string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
ProjectID *string `json:"project_id"`
Position *float64 `json:"position"`
DueDate *string `json:"due_date"`
}
@@ -360,7 +345,6 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
AssigneeType: prevIssue.AssigneeType,
AssigneeID: prevIssue.AssigneeID,
DueDate: prevIssue.DueDate,
ProjectID: prevIssue.ProjectID,
}
// COALESCE fields — only set when explicitly provided
@@ -406,13 +390,6 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
params.DueDate = pgtype.Timestamptz{Valid: false} // explicit null = clear date
}
}
if _, ok := rawFields["project_id"]; ok {
if req.ProjectID != nil {
params.ProjectID = parseUUID(*req.ProjectID)
} else {
params.ProjectID = pgtype.UUID{Valid: false} // explicit null = remove from project
}
}
// Enforce agent visibility: private agents can only be assigned by owner/admin.
if req.AssigneeType != nil && *req.AssigneeType == "agent" && req.AssigneeID != nil {
@@ -672,7 +649,6 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
AssigneeType: prevIssue.AssigneeType,
AssigneeID: prevIssue.AssigneeID,
DueDate: prevIssue.DueDate,
ProjectID: prevIssue.ProjectID,
}
if req.Updates.Title != nil {
@@ -715,13 +691,6 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
params.DueDate = pgtype.Timestamptz{Valid: false}
}
}
if _, ok := rawUpdates["project_id"]; ok {
if req.Updates.ProjectID != nil {
params.ProjectID = parseUUID(*req.Updates.ProjectID)
} else {
params.ProjectID = pgtype.UUID{Valid: false}
}
}
// Enforce agent visibility for batch assignment.
if req.Updates.AssigneeType != nil && *req.Updates.AssigneeType == "agent" && req.Updates.AssigneeID != nil {

View File

@@ -0,0 +1,209 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// IssueDependencyResponse is the JSON response for an issue dependency.
type IssueDependencyResponse struct {
ID string `json:"id"`
IssueID string `json:"issue_id"`
DependsOnIssueID string `json:"depends_on_issue_id"`
Type string `json:"type"`
// Enriched fields for the related issue
IssueIdentifier string `json:"issue_identifier"`
IssueTitle string `json:"issue_title"`
DependsOnIssueIdentifier string `json:"depends_on_issue_identifier"`
DependsOnIssueTitle string `json:"depends_on_issue_title"`
}
// ListIssueDependencies returns all dependencies for a given issue.
func (h *Handler) ListIssueDependencies(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, id)
if !ok {
return
}
deps, err := h.Queries.ListIssueDependencies(r.Context(), issue.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list dependencies")
return
}
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
result := make([]IssueDependencyResponse, 0, len(deps))
for _, d := range deps {
resp := IssueDependencyResponse{
ID: uuidToString(d.ID),
IssueID: uuidToString(d.IssueID),
DependsOnIssueID: uuidToString(d.DependsOnIssueID),
Type: d.Type,
}
// Enrich with issue identifiers and titles
if srcIssue, err := h.Queries.GetIssue(r.Context(), d.IssueID); err == nil {
resp.IssueIdentifier = prefix + "-" + strconv.Itoa(int(srcIssue.Number))
resp.IssueTitle = srcIssue.Title
}
if depIssue, err := h.Queries.GetIssue(r.Context(), d.DependsOnIssueID); err == nil {
resp.DependsOnIssueIdentifier = prefix + "-" + strconv.Itoa(int(depIssue.Number))
resp.DependsOnIssueTitle = depIssue.Title
}
result = append(result, resp)
}
writeJSON(w, http.StatusOK, result)
}
// CreateIssueDependency creates a new dependency between two issues.
func (h *Handler) CreateIssueDependency(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, id)
if !ok {
return
}
var req struct {
DependsOnIssueID string `json:"depends_on_issue_id"`
Type string `json:"type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.DependsOnIssueID == "" || req.Type == "" {
writeError(w, http.StatusBadRequest, "depends_on_issue_id and type are required")
return
}
// Validate relation type
switch req.Type {
case "blocks", "blocked_by", "related":
default:
writeError(w, http.StatusBadRequest, "type must be one of: blocks, blocked_by, related")
return
}
// Prevent self-reference
if uuidToString(issue.ID) == req.DependsOnIssueID {
writeError(w, http.StatusBadRequest, "cannot create a dependency to the same issue")
return
}
// Verify the target issue exists and belongs to the same workspace
targetIssue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
ID: parseUUID(req.DependsOnIssueID),
WorkspaceID: issue.WorkspaceID,
})
if err != nil {
writeError(w, http.StatusNotFound, "target issue not found")
return
}
dep, err := h.Queries.CreateIssueDependency(r.Context(), db.CreateIssueDependencyParams{
IssueID: issue.ID,
DependsOnIssueID: parseUUID(req.DependsOnIssueID),
Type: req.Type,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "dependency already exists")
return
}
writeError(w, http.StatusInternalServerError, "failed to create dependency")
return
}
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
issueIdentifier := prefix + "-" + strconv.Itoa(int(issue.Number))
targetIdentifier := prefix + "-" + strconv.Itoa(int(targetIssue.Number))
userID := requestUserID(r)
workspaceID := uuidToString(issue.WorkspaceID)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
// Publish event for activity listener
h.publish(protocol.EventIssueDependencyCreated, workspaceID, actorType, actorID, map[string]any{
"dependency": dep,
"issue": issue,
"target_issue": targetIssue,
"issue_identifier": issueIdentifier,
"target_issue_identifier": targetIdentifier,
})
resp := IssueDependencyResponse{
ID: uuidToString(dep.ID),
IssueID: uuidToString(dep.IssueID),
DependsOnIssueID: uuidToString(dep.DependsOnIssueID),
Type: dep.Type,
IssueIdentifier: issueIdentifier,
IssueTitle: issue.Title,
DependsOnIssueIdentifier: targetIdentifier,
DependsOnIssueTitle: targetIssue.Title,
}
writeJSON(w, http.StatusCreated, resp)
}
// DeleteIssueDependency removes a dependency between two issues.
func (h *Handler) DeleteIssueDependency(w http.ResponseWriter, r *http.Request) {
issueIDStr := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, issueIDStr)
if !ok {
return
}
depID := chi.URLParam(r, "depId")
dep, err := h.Queries.GetIssueDependency(r.Context(), parseUUID(depID))
if err != nil {
writeError(w, http.StatusNotFound, "dependency not found")
return
}
// Verify the dependency belongs to this issue
depIssueID := uuidToString(dep.IssueID)
depTargetID := uuidToString(dep.DependsOnIssueID)
issueID := uuidToString(issue.ID)
if depIssueID != issueID && depTargetID != issueID {
writeError(w, http.StatusNotFound, "dependency not found")
return
}
// Look up both issues for activity details
srcIssue, _ := h.Queries.GetIssue(r.Context(), dep.IssueID)
targetIssue, _ := h.Queries.GetIssue(r.Context(), dep.DependsOnIssueID)
if err := h.Queries.DeleteIssueDependency(r.Context(), dep.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete dependency")
return
}
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
srcIdentifier := prefix + "-" + strconv.Itoa(int(srcIssue.Number))
targetIdentifier := prefix + "-" + strconv.Itoa(int(targetIssue.Number))
userID := requestUserID(r)
workspaceID := uuidToString(issue.WorkspaceID)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
h.publish(protocol.EventIssueDependencyRemoved, workspaceID, actorType, actorID, map[string]any{
"dependency": dep,
"issue": srcIssue,
"target_issue": targetIssue,
"issue_identifier": srcIdentifier,
"target_issue_identifier": targetIdentifier,
})
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -1,403 +0,0 @@
package handler
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"time"
"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"
)
// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------
type ProjectResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description *string `json:"description"`
Status string `json:"status"`
Icon *string `json:"icon"`
Color *string `json:"color"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
StartDate *string `json:"start_date"`
TargetDate *string `json:"target_date"`
SortOrder float64 `json:"sort_order"`
Progress *ProjectProgress `json:"progress,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ProjectProgress struct {
Total int32 `json:"total"`
Completed int32 `json:"completed"`
Percent float64 `json:"percent"`
}
func dateToPtr(d pgtype.Date) *string {
if !d.Valid {
return nil
}
s := d.Time.Format("2006-01-02")
return &s
}
func projectToResponse(p db.Project) ProjectResponse {
return ProjectResponse{
ID: uuidToString(p.ID),
WorkspaceID: uuidToString(p.WorkspaceID),
Name: p.Name,
Description: textToPtr(p.Description),
Status: p.Status,
Icon: textToPtr(p.Icon),
Color: textToPtr(p.Color),
LeadType: textToPtr(p.LeadType),
LeadID: uuidToPtr(p.LeadID),
StartDate: dateToPtr(p.StartDate),
TargetDate: dateToPtr(p.TargetDate),
SortOrder: p.SortOrder,
CreatedAt: timestampToString(p.CreatedAt),
UpdatedAt: timestampToString(p.UpdatedAt),
}
}
// ---------------------------------------------------------------------------
// Request types
// ---------------------------------------------------------------------------
type CreateProjectRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Status string `json:"status"`
Icon *string `json:"icon"`
Color *string `json:"color"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
StartDate *string `json:"start_date"`
TargetDate *string `json:"target_date"`
}
type UpdateProjectRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Status *string `json:"status"`
Icon *string `json:"icon"`
Color *string `json:"color"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
StartDate *string `json:"start_date"`
TargetDate *string `json:"target_date"`
SortOrder *float64 `json:"sort_order"`
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
workspaceID := resolveWorkspaceID(r)
var statusFilter pgtype.Text
if s := r.URL.Query().Get("status"); s != "" {
statusFilter = pgtype.Text{String: s, Valid: true}
}
projects, err := h.Queries.ListProjects(r.Context(), db.ListProjectsParams{
WorkspaceID: parseUUID(workspaceID),
Status: statusFilter,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list projects")
return
}
// Batch-load progress for all projects.
progressRows, _ := h.Queries.ListProjectsProgress(r.Context(), parseUUID(workspaceID))
progressMap := make(map[string]ProjectProgress, len(progressRows))
for _, row := range progressRows {
pid := uuidToString(row.ProjectID)
pct := float64(0)
if row.Total > 0 {
pct = float64(row.Completed) / float64(row.Total) * 100
}
progressMap[pid] = ProjectProgress{Total: row.Total, Completed: row.Completed, Percent: pct}
}
resp := make([]ProjectResponse, len(projects))
for i, p := range projects {
resp[i] = projectToResponse(p)
if prog, ok := progressMap[resp[i].ID]; ok {
resp[i].Progress = &prog
} else {
resp[i].Progress = &ProjectProgress{}
}
}
writeJSON(w, http.StatusOK, map[string]any{
"projects": resp,
"total": len(resp),
})
}
func (h *Handler) GetProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := resolveWorkspaceID(r)
project, err := h.Queries.GetProject(r.Context(), db.GetProjectParams{
ID: parseUUID(id),
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
resp := projectToResponse(project)
// Attach progress.
progress, err := h.Queries.GetProjectProgress(r.Context(), db.GetProjectProgressParams{
ProjectID: project.ID,
WorkspaceID: project.WorkspaceID,
})
if err == nil {
pct := float64(0)
if progress.Total > 0 {
pct = float64(progress.Completed) / float64(progress.Total) * 100
}
resp.Progress = &ProjectProgress{Total: progress.Total, Completed: progress.Completed, Percent: pct}
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
var req CreateProjectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
workspaceID := resolveWorkspaceID(r)
userID, ok := requireUserID(w, r)
if !ok {
return
}
status := req.Status
if status == "" {
status = "backlog"
}
var leadType pgtype.Text
var leadID pgtype.UUID
if req.LeadType != nil {
leadType = pgtype.Text{String: *req.LeadType, Valid: true}
}
if req.LeadID != nil {
leadID = parseUUID(*req.LeadID)
}
var startDate pgtype.Date
if req.StartDate != nil && *req.StartDate != "" {
t, err := time.Parse("2006-01-02", *req.StartDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
return
}
startDate = pgtype.Date{Time: t, Valid: true}
}
var targetDate pgtype.Date
if req.TargetDate != nil && *req.TargetDate != "" {
t, err := time.Parse("2006-01-02", *req.TargetDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid target_date format, expected YYYY-MM-DD")
return
}
targetDate = pgtype.Date{Time: t, Valid: true}
}
project, err := h.Queries.CreateProject(r.Context(), db.CreateProjectParams{
WorkspaceID: parseUUID(workspaceID),
Name: req.Name,
Description: ptrToText(req.Description),
Status: status,
Icon: ptrToText(req.Icon),
Color: ptrToText(req.Color),
LeadType: leadType,
LeadID: leadID,
StartDate: startDate,
TargetDate: targetDate,
SortOrder: 0,
})
if err != nil {
slog.Warn("create project failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to create project")
return
}
resp := projectToResponse(project)
resp.Progress = &ProjectProgress{}
slog.Info("project created", append(logger.RequestAttrs(r), "project_id", resp.ID, "name", project.Name, "workspace_id", workspaceID)...)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
h.publish(protocol.EventProjectCreated, workspaceID, actorType, actorID, map[string]any{"project": resp})
writeJSON(w, http.StatusCreated, resp)
}
func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := resolveWorkspaceID(r)
prevProject, err := h.Queries.GetProject(r.Context(), db.GetProjectParams{
ID: parseUUID(id),
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read request body")
return
}
var req UpdateProjectRequest
if err := json.Unmarshal(bodyBytes, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Track which fields were explicitly present in JSON.
var rawFields map[string]json.RawMessage
json.Unmarshal(bodyBytes, &rawFields)
params := db.UpdateProjectParams{
ID: prevProject.ID,
WorkspaceID: prevProject.WorkspaceID,
LeadType: prevProject.LeadType,
LeadID: prevProject.LeadID,
StartDate: prevProject.StartDate,
TargetDate: prevProject.TargetDate,
}
if req.Name != nil {
params.Name = pgtype.Text{String: *req.Name, Valid: true}
}
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
}
if req.Status != nil {
params.Status = pgtype.Text{String: *req.Status, Valid: true}
}
if req.Icon != nil {
params.Icon = pgtype.Text{String: *req.Icon, Valid: true}
}
if req.Color != nil {
params.Color = pgtype.Text{String: *req.Color, Valid: true}
}
if req.SortOrder != nil {
params.SortOrder = pgtype.Float8{Float64: *req.SortOrder, Valid: true}
}
// Nullable fields — only override when explicitly present.
if _, ok := rawFields["lead_type"]; ok {
if req.LeadType != nil {
params.LeadType = pgtype.Text{String: *req.LeadType, Valid: true}
} else {
params.LeadType = pgtype.Text{Valid: false}
}
}
if _, ok := rawFields["lead_id"]; ok {
if req.LeadID != nil {
params.LeadID = parseUUID(*req.LeadID)
} else {
params.LeadID = pgtype.UUID{Valid: false}
}
}
if _, ok := rawFields["start_date"]; ok {
if req.StartDate != nil && *req.StartDate != "" {
t, err := time.Parse("2006-01-02", *req.StartDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
return
}
params.StartDate = pgtype.Date{Time: t, Valid: true}
} else {
params.StartDate = pgtype.Date{Valid: false}
}
}
if _, ok := rawFields["target_date"]; ok {
if req.TargetDate != nil && *req.TargetDate != "" {
t, err := time.Parse("2006-01-02", *req.TargetDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid target_date format, expected YYYY-MM-DD")
return
}
params.TargetDate = pgtype.Date{Time: t, Valid: true}
} else {
params.TargetDate = pgtype.Date{Valid: false}
}
}
project, err := h.Queries.UpdateProject(r.Context(), params)
if err != nil {
slog.Warn("update project failed", append(logger.RequestAttrs(r), "error", err, "project_id", id, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to update project")
return
}
resp := projectToResponse(project)
slog.Info("project updated", append(logger.RequestAttrs(r), "project_id", id, "workspace_id", workspaceID)...)
userID := requestUserID(r)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
h.publish(protocol.EventProjectUpdated, workspaceID, actorType, actorID, map[string]any{"project": resp})
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) DeleteProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := resolveWorkspaceID(r)
_, err := h.Queries.GetProject(r.Context(), db.GetProjectParams{
ID: parseUUID(id),
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
err = h.Queries.DeleteProject(r.Context(), db.DeleteProjectParams{
ID: parseUUID(id),
WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete project")
return
}
userID := requestUserID(r)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
h.publish(protocol.EventProjectDeleted, workspaceID, actorType, actorID, map[string]any{"project_id": id})
slog.Info("project deleted", append(logger.RequestAttrs(r), "project_id", id, "workspace_id", workspaceID)...)
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -1,3 +0,0 @@
DROP INDEX IF EXISTS idx_issue_project;
ALTER TABLE issue DROP COLUMN IF EXISTS project_id;
DROP TABLE IF EXISTS project;

View File

@@ -1,25 +0,0 @@
-- Create project table
CREATE TABLE project (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'backlog'
CHECK (status IN ('backlog', 'planned', 'in_progress', 'completed', 'cancelled')),
icon TEXT,
color TEXT,
lead_type TEXT CHECK (lead_type IN ('member', 'agent')),
lead_id UUID,
start_date DATE,
target_date DATE,
sort_order FLOAT8 NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_project_workspace ON project(workspace_id);
CREATE INDEX idx_project_workspace_status ON project(workspace_id, status);
-- Add project_id to issue
ALTER TABLE issue ADD COLUMN project_id UUID REFERENCES project(id) ON DELETE SET NULL;
CREATE INDEX idx_issue_project ON issue(workspace_id, project_id);

View File

@@ -15,10 +15,10 @@ const createIssue = `-- name: CreateIssue :one
INSERT INTO issue (
workspace_id, title, description, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
parent_issue_id, position, due_date, number, project_id
parent_issue_id, position, due_date, number
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number
`
type CreateIssueParams struct {
@@ -35,7 +35,6 @@ type CreateIssueParams struct {
Position float64 `json:"position"`
DueDate pgtype.Timestamptz `json:"due_date"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
}
func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) {
@@ -53,7 +52,6 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
arg.Position,
arg.DueDate,
arg.Number,
arg.ProjectID,
)
var i Issue
err := row.Scan(
@@ -75,7 +73,6 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}
@@ -90,7 +87,7 @@ func (q *Queries) DeleteIssue(ctx context.Context, id pgtype.UUID) error {
}
const getIssue = `-- name: GetIssue :one
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id FROM issue
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
WHERE id = $1
`
@@ -116,13 +113,12 @@ func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}
const getIssueByNumber = `-- name: GetIssueByNumber :one
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id FROM issue
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
WHERE workspace_id = $1 AND number = $2
`
@@ -153,13 +149,12 @@ func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberPara
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}
const getIssueInWorkspace = `-- name: GetIssueInWorkspace :one
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id FROM issue
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
WHERE id = $1 AND workspace_id = $2
`
@@ -190,18 +185,16 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}
const listIssues = `-- name: ListIssues :many
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id FROM issue
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue
WHERE workspace_id = $1
AND ($4::text IS NULL OR status = $4)
AND ($5::text IS NULL OR priority = $5)
AND ($6::uuid IS NULL OR assignee_id = $6)
AND ($7::uuid IS NULL OR project_id = $7)
ORDER BY position ASC, created_at DESC
LIMIT $2 OFFSET $3
`
@@ -213,7 +206,6 @@ type ListIssuesParams struct {
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
AssigneeID pgtype.UUID `json:"assignee_id"`
ProjectID pgtype.UUID `json:"project_id"`
}
func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue, error) {
@@ -224,7 +216,6 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue
arg.Status,
arg.Priority,
arg.AssigneeID,
arg.ProjectID,
)
if err != nil {
return nil, err
@@ -252,7 +243,6 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
); err != nil {
return nil, err
}
@@ -274,10 +264,9 @@ UPDATE issue SET
assignee_id = $7,
position = COALESCE($8, position),
due_date = $9,
project_id = $10,
updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number
`
type UpdateIssueParams struct {
@@ -290,7 +279,6 @@ type UpdateIssueParams struct {
AssigneeID pgtype.UUID `json:"assignee_id"`
Position pgtype.Float8 `json:"position"`
DueDate pgtype.Timestamptz `json:"due_date"`
ProjectID pgtype.UUID `json:"project_id"`
}
func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) {
@@ -304,7 +292,6 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue
arg.AssigneeID,
arg.Position,
arg.DueDate,
arg.ProjectID,
)
var i Issue
err := row.Scan(
@@ -326,7 +313,6 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}
@@ -336,7 +322,7 @@ UPDATE issue SET
status = $2,
updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number
`
type UpdateIssueStatusParams struct {
@@ -366,7 +352,6 @@ func (q *Queries) UpdateIssueStatus(ctx context.Context, arg UpdateIssueStatusPa
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}

View File

@@ -0,0 +1,91 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: issue_dependency.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createIssueDependency = `-- name: CreateIssueDependency :one
INSERT INTO issue_dependency (issue_id, depends_on_issue_id, type)
VALUES ($1, $2, $3)
RETURNING id, issue_id, depends_on_issue_id, type
`
type CreateIssueDependencyParams struct {
IssueID pgtype.UUID `json:"issue_id"`
DependsOnIssueID pgtype.UUID `json:"depends_on_issue_id"`
Type string `json:"type"`
}
func (q *Queries) CreateIssueDependency(ctx context.Context, arg CreateIssueDependencyParams) (IssueDependency, error) {
row := q.db.QueryRow(ctx, createIssueDependency, arg.IssueID, arg.DependsOnIssueID, arg.Type)
var i IssueDependency
err := row.Scan(
&i.ID,
&i.IssueID,
&i.DependsOnIssueID,
&i.Type,
)
return i, err
}
const deleteIssueDependency = `-- name: DeleteIssueDependency :exec
DELETE FROM issue_dependency WHERE id = $1
`
func (q *Queries) DeleteIssueDependency(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteIssueDependency, id)
return err
}
const getIssueDependency = `-- name: GetIssueDependency :one
SELECT id, issue_id, depends_on_issue_id, type FROM issue_dependency WHERE id = $1
`
func (q *Queries) GetIssueDependency(ctx context.Context, id pgtype.UUID) (IssueDependency, error) {
row := q.db.QueryRow(ctx, getIssueDependency, id)
var i IssueDependency
err := row.Scan(
&i.ID,
&i.IssueID,
&i.DependsOnIssueID,
&i.Type,
)
return i, err
}
const listIssueDependencies = `-- name: ListIssueDependencies :many
SELECT id, issue_id, depends_on_issue_id, type FROM issue_dependency
WHERE issue_id = $1 OR depends_on_issue_id = $1
`
func (q *Queries) ListIssueDependencies(ctx context.Context, issueID pgtype.UUID) ([]IssueDependency, error) {
rows, err := q.db.Query(ctx, listIssueDependencies, issueID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []IssueDependency{}
for rows.Next() {
var i IssueDependency
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.DependsOnIssueID,
&i.Type,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -175,7 +175,6 @@ type Issue struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
}
type IssueDependency struct {
@@ -235,23 +234,6 @@ type PersonalAccessToken struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Project struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Icon pgtype.Text `json:"icon"`
Color pgtype.Text `json:"color"`
LeadType pgtype.Text `json:"lead_type"`
LeadID pgtype.UUID `json:"lead_id"`
StartDate pgtype.Date `json:"start_date"`
TargetDate pgtype.Date `json:"target_date"`
SortOrder float64 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type RuntimeUsage struct {
ID pgtype.UUID `json:"id"`
RuntimeID pgtype.UUID `json:"runtime_id"`

View File

@@ -1,292 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: project.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createProject = `-- name: CreateProject :one
INSERT INTO project (
workspace_id, name, description, status, icon, color,
lead_type, lead_id, start_date, target_date, sort_order
) VALUES (
$1, $2, $3, $4, $5, $6,
$8, $9,
$10, $11, $7
) RETURNING id, workspace_id, name, description, status, icon, color, lead_type, lead_id, start_date, target_date, sort_order, created_at, updated_at
`
type CreateProjectParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Icon pgtype.Text `json:"icon"`
Color pgtype.Text `json:"color"`
SortOrder float64 `json:"sort_order"`
LeadType pgtype.Text `json:"lead_type"`
LeadID pgtype.UUID `json:"lead_id"`
StartDate pgtype.Date `json:"start_date"`
TargetDate pgtype.Date `json:"target_date"`
}
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) {
row := q.db.QueryRow(ctx, createProject,
arg.WorkspaceID,
arg.Name,
arg.Description,
arg.Status,
arg.Icon,
arg.Color,
arg.SortOrder,
arg.LeadType,
arg.LeadID,
arg.StartDate,
arg.TargetDate,
)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.Status,
&i.Icon,
&i.Color,
&i.LeadType,
&i.LeadID,
&i.StartDate,
&i.TargetDate,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteProject = `-- name: DeleteProject :exec
DELETE FROM project WHERE id = $1 AND workspace_id = $2
`
type DeleteProjectParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) DeleteProject(ctx context.Context, arg DeleteProjectParams) error {
_, err := q.db.Exec(ctx, deleteProject, arg.ID, arg.WorkspaceID)
return err
}
const getProject = `-- name: GetProject :one
SELECT id, workspace_id, name, description, status, icon, color, lead_type, lead_id, start_date, target_date, sort_order, created_at, updated_at FROM project
WHERE id = $1 AND workspace_id = $2
`
type GetProjectParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetProject(ctx context.Context, arg GetProjectParams) (Project, error) {
row := q.db.QueryRow(ctx, getProject, arg.ID, arg.WorkspaceID)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.Status,
&i.Icon,
&i.Color,
&i.LeadType,
&i.LeadID,
&i.StartDate,
&i.TargetDate,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getProjectProgress = `-- name: GetProjectProgress :one
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status IN ('done', 'cancelled'))::int AS completed
FROM issue
WHERE project_id = $1 AND workspace_id = $2
`
type GetProjectProgressParams struct {
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
type GetProjectProgressRow struct {
Total int32 `json:"total"`
Completed int32 `json:"completed"`
}
func (q *Queries) GetProjectProgress(ctx context.Context, arg GetProjectProgressParams) (GetProjectProgressRow, error) {
row := q.db.QueryRow(ctx, getProjectProgress, arg.ProjectID, arg.WorkspaceID)
var i GetProjectProgressRow
err := row.Scan(&i.Total, &i.Completed)
return i, err
}
const listProjects = `-- name: ListProjects :many
SELECT id, workspace_id, name, description, status, icon, color, lead_type, lead_id, start_date, target_date, sort_order, created_at, updated_at FROM project
WHERE workspace_id = $1
AND ($2::text IS NULL OR status = $2)
ORDER BY sort_order ASC, created_at DESC
`
type ListProjectsParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Status pgtype.Text `json:"status"`
}
func (q *Queries) ListProjects(ctx context.Context, arg ListProjectsParams) ([]Project, error) {
rows, err := q.db.Query(ctx, listProjects, arg.WorkspaceID, arg.Status)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Project{}
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.Status,
&i.Icon,
&i.Color,
&i.LeadType,
&i.LeadID,
&i.StartDate,
&i.TargetDate,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectsProgress = `-- name: ListProjectsProgress :many
SELECT
project_id,
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status IN ('done', 'cancelled'))::int AS completed
FROM issue
WHERE workspace_id = $1 AND project_id IS NOT NULL
GROUP BY project_id
`
type ListProjectsProgressRow struct {
ProjectID pgtype.UUID `json:"project_id"`
Total int32 `json:"total"`
Completed int32 `json:"completed"`
}
func (q *Queries) ListProjectsProgress(ctx context.Context, workspaceID pgtype.UUID) ([]ListProjectsProgressRow, error) {
rows, err := q.db.Query(ctx, listProjectsProgress, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListProjectsProgressRow{}
for rows.Next() {
var i ListProjectsProgressRow
if err := rows.Scan(&i.ProjectID, &i.Total, &i.Completed); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateProject = `-- name: UpdateProject :one
UPDATE project SET
name = COALESCE($3, name),
description = COALESCE($4, description),
status = COALESCE($5, status),
icon = COALESCE($6, icon),
color = COALESCE($7, color),
lead_type = $8,
lead_id = $9,
start_date = $10,
target_date = $11,
sort_order = COALESCE($12, sort_order),
updated_at = now()
WHERE id = $1 AND workspace_id = $2
RETURNING id, workspace_id, name, description, status, icon, color, lead_type, lead_id, start_date, target_date, sort_order, created_at, updated_at
`
type UpdateProjectParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Status pgtype.Text `json:"status"`
Icon pgtype.Text `json:"icon"`
Color pgtype.Text `json:"color"`
LeadType pgtype.Text `json:"lead_type"`
LeadID pgtype.UUID `json:"lead_id"`
StartDate pgtype.Date `json:"start_date"`
TargetDate pgtype.Date `json:"target_date"`
SortOrder pgtype.Float8 `json:"sort_order"`
}
func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (Project, error) {
row := q.db.QueryRow(ctx, updateProject,
arg.ID,
arg.WorkspaceID,
arg.Name,
arg.Description,
arg.Status,
arg.Icon,
arg.Color,
arg.LeadType,
arg.LeadID,
arg.StartDate,
arg.TargetDate,
arg.SortOrder,
)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Description,
&i.Status,
&i.Icon,
&i.Color,
&i.LeadType,
&i.LeadID,
&i.StartDate,
&i.TargetDate,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@@ -4,7 +4,6 @@ WHERE workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id'))
ORDER BY position ASC, created_at DESC
LIMIT $2 OFFSET $3;
@@ -20,9 +19,9 @@ WHERE id = $1 AND workspace_id = $2;
INSERT INTO issue (
workspace_id, title, description, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
parent_issue_id, position, due_date, number, project_id
parent_issue_id, position, due_date, number
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, sqlc.narg('project_id')
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
) RETURNING *;
-- name: GetIssueByNumber :one
@@ -39,7 +38,6 @@ UPDATE issue SET
assignee_id = sqlc.narg('assignee_id'),
position = COALESCE(sqlc.narg('position'), position),
due_date = sqlc.narg('due_date'),
project_id = sqlc.narg('project_id'),
updated_at = now()
WHERE id = $1
RETURNING *;

View File

@@ -0,0 +1,14 @@
-- name: CreateIssueDependency :one
INSERT INTO issue_dependency (issue_id, depends_on_issue_id, type)
VALUES ($1, $2, $3)
RETURNING *;
-- name: DeleteIssueDependency :exec
DELETE FROM issue_dependency WHERE id = $1;
-- name: GetIssueDependency :one
SELECT * FROM issue_dependency WHERE id = $1;
-- name: ListIssueDependencies :many
SELECT * FROM issue_dependency
WHERE issue_id = $1 OR depends_on_issue_id = $1;

View File

@@ -1,54 +0,0 @@
-- name: CreateProject :one
INSERT INTO project (
workspace_id, name, description, status, icon, color,
lead_type, lead_id, start_date, target_date, sort_order
) VALUES (
$1, $2, $3, $4, $5, $6,
sqlc.narg('lead_type'), sqlc.narg('lead_id'),
sqlc.narg('start_date'), sqlc.narg('target_date'), $7
) RETURNING *;
-- name: GetProject :one
SELECT * FROM project
WHERE id = $1 AND workspace_id = $2;
-- name: ListProjects :many
SELECT * FROM project
WHERE workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
ORDER BY sort_order ASC, created_at DESC;
-- name: UpdateProject :one
UPDATE project SET
name = COALESCE(sqlc.narg('name'), name),
description = COALESCE(sqlc.narg('description'), description),
status = COALESCE(sqlc.narg('status'), status),
icon = COALESCE(sqlc.narg('icon'), icon),
color = COALESCE(sqlc.narg('color'), color),
lead_type = sqlc.narg('lead_type'),
lead_id = sqlc.narg('lead_id'),
start_date = sqlc.narg('start_date'),
target_date = sqlc.narg('target_date'),
sort_order = COALESCE(sqlc.narg('sort_order'), sort_order),
updated_at = now()
WHERE id = $1 AND workspace_id = $2
RETURNING *;
-- name: DeleteProject :exec
DELETE FROM project WHERE id = $1 AND workspace_id = $2;
-- name: GetProjectProgress :one
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status IN ('done', 'cancelled'))::int AS completed
FROM issue
WHERE project_id = $1 AND workspace_id = $2;
-- name: ListProjectsProgress :many
SELECT
project_id,
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE status IN ('done', 'cancelled'))::int AS completed
FROM issue
WHERE workspace_id = $1 AND project_id IS NOT NULL
GROUP BY project_id;

View File

@@ -7,6 +7,10 @@ const (
EventIssueUpdated = "issue:updated"
EventIssueDeleted = "issue:deleted"
// Issue dependency events
EventIssueDependencyCreated = "issue_dependency:created"
EventIssueDependencyRemoved = "issue_dependency:removed"
// Comment events
EventCommentCreated = "comment:created"
EventCommentUpdated = "comment:updated"
@@ -58,11 +62,6 @@ const (
EventSkillUpdated = "skill:updated"
EventSkillDeleted = "skill:deleted"
// Project events
EventProjectCreated = "project:created"
EventProjectUpdated = "project:updated"
EventProjectDeleted = "project:deleted"
// Daemon events
EventDaemonHeartbeat = "daemon:heartbeat"
EventDaemonRegister = "daemon:register"