mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-21 06:25:56 +02:00
Compare commits
2 Commits
feature/pr
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bc8dcc053 | ||
|
|
473ec17c65 |
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -220,7 +220,6 @@ vi.mock("@dnd-kit/utilities", () => ({
|
||||
|
||||
const issueDefaults = {
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position: 0,
|
||||
};
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ProjectsPage } from "@/features/projects/components/projects-page";
|
||||
|
||||
export default function Page() {
|
||||
return <ProjectsPage />;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
@@ -1 +0,0 @@
|
||||
export { useProjectStore } from "./store";
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
209
server/internal/handler/issue_dependency.go
Normal file
209
server/internal/handler/issue_dependency.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
91
server/pkg/db/generated/issue_dependency.sql.go
Normal file
91
server/pkg/db/generated/issue_dependency.sql.go
Normal 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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 *;
|
||||
|
||||
14
server/pkg/db/queries/issue_dependency.sql
Normal file
14
server/pkg/db/queries/issue_dependency.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user