Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
4dd6534e4a feat(projects): add Project entity with full-stack CRUD support
Implements the Project concept as a higher-level grouping for issues.
Hierarchy: workspace → project → issue → sub-issue.

Backend:
- Migration 034: project table + issue.project_id FK
- sqlc queries for project CRUD
- Project handler with list/get/create/update/delete
- Issue handler updated to support project_id in create/update
- Routes at /api/projects, WebSocket event constants

Frontend (new monorepo structure):
- @multica/core: Project types, API client methods, queries/mutations,
  status config, realtime sync
- @multica/views: Projects list page, detail page (overview + issues
  tabs), project picker for issue detail panel
- apps/web: Route pages, sidebar navigation entry

All TypeScript type checks and tests pass.
2026-04-09 14:51:50 +08:00
35 changed files with 1225 additions and 15 deletions

View File

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

View File

@@ -385,6 +385,7 @@ const mockIssue: Issue = {
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 0,
due_date: "2026-06-01T00:00:00Z",
created_at: "2026-01-15T00:00:00Z",

View File

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

View File

@@ -0,0 +1,13 @@
"use client";
import { use } from "react";
import { ProjectDetail } from "@multica/views/projects/components";
export default function ProjectDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <ProjectDetail projectId={id} />;
}

View File

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

View File

@@ -36,6 +36,10 @@ import type {
TimelineEntry,
TaskMessagePayload,
Attachment,
Project,
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
} from "../types";
import { type Logger, noopLogger } from "../logger";
@@ -607,4 +611,35 @@ 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> {
const search = new URLSearchParams();
if (this.workspaceId) search.set("workspace_id", this.workspaceId);
return this.fetch(`/api/projects?${search}`, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
return this.fetch(`/api/projects/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteProject(id: string): Promise<void> {
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
}
}

View File

@@ -33,6 +33,10 @@
"./runtimes/queries": "./runtimes/queries.ts",
"./runtimes/mutations": "./runtimes/mutations.ts",
"./runtimes/hooks": "./runtimes/hooks.ts",
"./projects": "./projects/index.ts",
"./projects/queries": "./projects/queries.ts",
"./projects/mutations": "./projects/mutations.ts",
"./projects/config": "./projects/config.ts",
"./realtime": "./realtime/index.ts",
"./navigation": "./navigation/index.ts",
"./modals": "./modals/index.ts",

View File

@@ -0,0 +1,20 @@
import type { ProjectStatus } from "../types";
export const PROJECT_STATUS_ORDER: ProjectStatus[] = [
"planned",
"in_progress",
"paused",
"completed",
"cancelled",
];
export const PROJECT_STATUS_CONFIG: Record<
ProjectStatus,
{ label: string; color: string; badgeBg: string; badgeText: string }
> = {
planned: { label: "Planned", color: "text-muted-foreground", badgeBg: "bg-muted", badgeText: "text-muted-foreground" },
in_progress: { label: "In Progress", color: "text-warning", badgeBg: "bg-warning", badgeText: "text-white" },
paused: { label: "Paused", color: "text-muted-foreground", badgeBg: "bg-muted", badgeText: "text-muted-foreground" },
completed: { label: "Completed", color: "text-info", badgeBg: "bg-info", badgeText: "text-white" },
cancelled: { label: "Cancelled", color: "text-muted-foreground", badgeBg: "bg-muted", badgeText: "text-muted-foreground" },
};

View File

@@ -0,0 +1,2 @@
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";

View File

@@ -0,0 +1,75 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { projectKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { Project, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "../types";
export function useCreateProject() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: CreateProjectRequest) => api.createProject(data),
onSuccess: (newProject) => {
qc.setQueryData<ListProjectsResponse>(projectKeys.list(wsId), (old) =>
old && !old.projects.some((p) => p.id === newProject.id)
? { ...old, projects: [...old.projects, newProject], total: old.total + 1 }
: old,
);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: projectKeys.list(wsId) });
},
});
}
export function useUpdateProject() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ id, ...data }: { id: string } & UpdateProjectRequest) =>
api.updateProject(id, data),
onMutate: ({ id, ...data }) => {
qc.cancelQueries({ queryKey: projectKeys.list(wsId) });
const prevList = qc.getQueryData<ListProjectsResponse>(projectKeys.list(wsId));
const prevDetail = qc.getQueryData<Project>(projectKeys.detail(wsId, id));
qc.setQueryData<ListProjectsResponse>(projectKeys.list(wsId), (old) =>
old ? { ...old, projects: old.projects.map((p) => (p.id === id ? { ...p, ...data } : p)) } : old,
);
qc.setQueryData<Project>(projectKeys.detail(wsId, id), (old) =>
old ? { ...old, ...data } : old,
);
return { prevList, prevDetail, id };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevList) qc.setQueryData(projectKeys.list(wsId), ctx.prevList);
if (ctx?.prevDetail) qc.setQueryData(projectKeys.detail(wsId, ctx.id), ctx.prevDetail);
},
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: projectKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: projectKeys.list(wsId) });
},
});
}
export function useDeleteProject() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.deleteProject(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: projectKeys.list(wsId) });
const prevList = qc.getQueryData<ListProjectsResponse>(projectKeys.list(wsId));
qc.setQueryData<ListProjectsResponse>(projectKeys.list(wsId), (old) =>
old ? { ...old, projects: old.projects.filter((p) => p.id !== id), total: old.total - 1 } : old,
);
qc.removeQueries({ queryKey: projectKeys.detail(wsId, id) });
return { prevList };
},
onError: (_err, _id, ctx) => {
if (ctx?.prevList) qc.setQueryData(projectKeys.list(wsId), ctx.prevList);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: projectKeys.list(wsId) });
},
});
}

View File

@@ -0,0 +1,24 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const projectKeys = {
all: (wsId: string) => ["projects", wsId] as const,
list: (wsId: string) => [...projectKeys.all(wsId), "list"] as const,
detail: (wsId: string, id: string) =>
[...projectKeys.all(wsId), "detail", id] as const,
};
export function projectListOptions(wsId: string) {
return queryOptions({
queryKey: projectKeys.list(wsId),
queryFn: () => api.listProjects(),
select: (data) => data.projects,
});
}
export function projectDetailOptions(wsId: string, id: string) {
return queryOptions({
queryKey: projectKeys.detail(wsId, id),
queryFn: () => api.getProject(id),
});
}

View File

@@ -8,6 +8,7 @@ import type { AuthState } from "../auth/store";
import type { WorkspaceStore } from "../workspace/store";
import { createLogger } from "../logger";
import { issueKeys } from "../issues/queries";
import { projectKeys } from "../projects/queries";
import {
onIssueCreated,
onIssueUpdated,
@@ -90,6 +91,10 @@ export function useRealtimeSync(
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
},
project: () => {
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
},
};
const timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -294,6 +299,7 @@ export function useRealtimeSync(
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
}
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
} catch (e) {

View File

@@ -10,6 +10,7 @@ export interface CreateIssueRequest {
assignee_type?: IssueAssigneeType;
assignee_id?: string;
parent_issue_id?: string;
project_id?: string;
due_date?: string;
attachment_ids?: string[];
}
@@ -24,6 +25,7 @@ export interface UpdateIssueRequest {
position?: number;
due_date?: string | null;
parent_issue_id?: string | null;
project_id?: string | null;
}
export interface ListIssuesParams {

View File

@@ -4,6 +4,7 @@ import type { InboxItem } from "./inbox";
import type { Comment, Reaction } from "./comment";
import type { TimelineEntry } from "./activity";
import type { Workspace, MemberWithUser } from "./workspace";
import type { Project } from "./project";
// WebSocket event types (matching Go server protocol/events.go)
export type WSEventType =
@@ -44,7 +45,10 @@ export type WSEventType =
| "reaction:added"
| "reaction:removed"
| "issue_reaction:added"
| "issue_reaction:removed";
| "issue_reaction:removed"
| "project:created"
| "project:updated"
| "project:deleted";
export interface WSMessage<T = unknown> {
type: WSEventType;
@@ -215,3 +219,15 @@ export interface IssueReactionRemovedPayload {
actor_type: string;
actor_id: string;
}
export interface ProjectCreatedPayload {
project: Project;
}
export interface ProjectUpdatedPayload {
project: Project;
}
export interface ProjectDeletedPayload {
project_id: string;
}

View File

@@ -30,3 +30,4 @@ export type * from "./events";
export type * from "./api";
export type { Attachment } from "./attachment";
export type { StorageAdapter } from "./storage";
export type { Project, ProjectStatus, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";

View File

@@ -34,6 +34,7 @@ export interface Issue {
creator_type: IssueAssigneeType;
creator_id: string;
parent_issue_id: string | null;
project_id: string | null;
position: number;
due_date: string | null;
reactions?: IssueReaction[];

View File

@@ -0,0 +1,37 @@
export type ProjectStatus = "planned" | "in_progress" | "paused" | "completed" | "cancelled";
export interface Project {
id: string;
workspace_id: string;
title: string;
description: string | null;
icon: string | null;
status: ProjectStatus;
lead_type: "member" | "agent" | null;
lead_id: string | null;
created_at: string;
updated_at: string;
}
export interface CreateProjectRequest {
title: string;
description?: string;
icon?: string;
status?: ProjectStatus;
lead_type?: "member" | "agent";
lead_id?: string;
}
export interface UpdateProjectRequest {
title?: string;
description?: string | null;
icon?: string | null;
status?: ProjectStatus;
lead_type?: "member" | "agent" | null;
lead_id?: string | null;
}
export interface ListProjectsResponse {
projects: Project[];
total: number;
}

View File

@@ -61,6 +61,7 @@ import { ActorAvatar } from "../../common/actor-avatar";
import type { Issue, UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@multica/core/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from ".";
import { ProjectPicker } from "../../projects/components/project-picker";
import { CommentCard } from "./comment-card";
import { CommentInput } from "./comment-input";
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
@@ -1211,6 +1212,14 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
onUpdate={handleUpdateField}
/>
</PropRow>
{/* Project */}
<PropRow label="Project">
<ProjectPicker
projectId={issue.project_id}
onUpdate={handleUpdateField}
/>
</PropRow>
</div>}
</div>

View File

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

View File

@@ -13,6 +13,7 @@
"./issues/utils/filter": "./issues/utils/filter.ts",
"./issues/utils/sort": "./issues/utils/sort.ts",
"./issues/utils/redact": "./issues/utils/redact.ts",
"./projects/components": "./projects/components/index.ts",
"./modals/registry": "./modals/registry.tsx",
"./modals/create-issue": "./modals/create-issue.tsx",
"./modals/create-workspace": "./modals/create-workspace.tsx",

View File

@@ -0,0 +1,3 @@
export { ProjectsPage } from "./projects-page";
export { ProjectDetail } from "./project-detail";
export { ProjectPicker } from "./project-picker";

View File

@@ -0,0 +1,191 @@
"use client";
import { useMemo, useState } from "react";
import { ArrowLeft, Check, Trash2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
import { issueListOptions } from "@multica/core/issues/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { PROJECT_STATUS_ORDER, PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
import { STATUS_CONFIG } from "@multica/core/issues/config";
import { StatusIcon } from "../../issues/components/status-icon";
import { AppLink, useNavigation } from "../../navigation";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@multica/ui/components/ui/tabs";
import { Textarea } from "@multica/ui/components/ui/textarea";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import type { Issue } from "@multica/core/types";
function IssueRow({ issue }: { issue: Issue }) {
return (
<AppLink
href={`/issues/${issue.id}`}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent/50 transition-colors"
>
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground shrink-0 text-xs">{issue.identifier}</span>
<span className="truncate">{issue.title}</span>
<span className={`ml-auto shrink-0 text-xs ${STATUS_CONFIG[issue.status].iconColor}`}>
{STATUS_CONFIG[issue.status].label}
</span>
</AppLink>
);
}
export function ProjectDetail({ projectId }: { projectId: string }) {
const wsId = useWorkspaceId();
const router = useNavigation();
const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId));
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
const updateProject = useUpdateProject();
const deleteProject = useDeleteProject();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const projectIssues = useMemo(
() => allIssues.filter((i) => i.project_id === projectId),
[allIssues, projectId],
);
if (isLoading) {
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>;
}
const statusCfg = PROJECT_STATUS_CONFIG[project.status];
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 border-b px-6 py-3">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => router.push("/projects")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="text-lg">{project.icon || "📁"}</span>
<h1 className="text-sm font-medium flex-1 truncate">{project.title}</h1>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm">
<span className={`inline-flex items-center gap-1 ${statusCfg.color}`}>{statusCfg.label}</span>
</Button>
}
/>
<DropdownMenuContent align="end" className="w-44">
{PROJECT_STATUS_ORDER.map((s) => (
<DropdownMenuItem key={s} onClick={() => updateProject.mutate({ id: project.id, 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>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive" onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete project</AlertDialogTitle>
<AlertDialogDescription>This will delete the project. Issues will not be deleted but will be unlinked.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteProject.mutate(project.id, { onSuccess: () => router.push("/projects") })}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Tabs defaultValue="overview" className="flex-1 flex flex-col overflow-hidden">
<TabsList className="mx-6 mt-3 w-fit">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="issues">Issues{projectIssues.length > 0 && ` (${projectIssues.length})`}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="flex-1 overflow-y-auto p-6 space-y-6">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">Description</label>
<Textarea
placeholder="Add a description..."
defaultValue={project.description ?? ""}
onBlur={(e) => {
const val = e.target.value;
if (val !== (project.description ?? "")) {
updateProject.mutate({ id: project.id, description: val || null });
}
}}
className="min-h-[100px] resize-none"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-2 block">Summary</label>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-lg border p-3">
<div className="text-2xl font-semibold">{projectIssues.length}</div>
<div className="text-xs text-muted-foreground">Total issues</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-2xl font-semibold">{projectIssues.filter((i) => i.status === "done").length}</div>
<div className="text-xs text-muted-foreground">Completed</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-2xl font-semibold">{projectIssues.filter((i) => i.status === "in_progress" || i.status === "in_review").length}</div>
<div className="text-xs text-muted-foreground">In progress</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-2xl font-semibold">{projectIssues.filter((i) => i.status === "todo" || i.status === "backlog").length}</div>
<div className="text-xs text-muted-foreground">Not started</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="issues" className="flex-1 overflow-y-auto p-6">
{projectIssues.length === 0 ? (
<div className="text-center py-12 text-muted-foreground text-sm">
No issues linked to this project yet.
<br />
<span className="text-xs">Assign issues to this project from the issue detail panel.</span>
</div>
) : (
<div className="space-y-0.5">
{projectIssues.map((issue) => (
<IssueRow key={issue.id} issue={issue} />
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { Check, FolderKanban, X } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { projectListOptions } from "@multica/core/projects/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import type { UpdateIssueRequest } from "@multica/core/types";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@multica/ui/components/ui/dropdown-menu";
export function ProjectPicker({
projectId,
onUpdate,
}: {
projectId: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
}) {
const wsId = useWorkspaceId();
const { data: projects = [] } = useQuery(projectListOptions(wsId));
const current = projects.find((p) => p.id === projectId);
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{current ? current.title : "No project"}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
{projects.map((p) => (
<DropdownMenuItem key={p.id} onClick={() => onUpdate({ project_id: p.id })}>
<span className="mr-1">{p.icon || "📁"}</span>
<span className="truncate">{p.title}</span>
{p.id === projectId && <Check className="ml-auto h-3.5 w-3.5 shrink-0" />}
</DropdownMenuItem>
))}
{projects.length > 0 && projectId && <DropdownMenuSeparator />}
{projectId && (
<DropdownMenuItem onClick={() => onUpdate({ project_id: null })}>
<X className="h-3.5 w-3.5 text-muted-foreground" />
Remove from project
</DropdownMenuItem>
)}
{projects.length === 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No projects yet</div>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { Plus, FolderKanban } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { projectListOptions } from "@multica/core/projects/queries";
import { useCreateProject } from "@multica/core/projects/mutations";
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
import { useWorkspaceId } from "@multica/core/hooks";
import { AppLink } from "../../navigation";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@multica/ui/components/ui/dialog";
import { Input } from "@multica/ui/components/ui/input";
import type { Project } from "@multica/core/types";
function ProjectCard({ project }: { project: Project }) {
const statusCfg = PROJECT_STATUS_CONFIG[project.status];
return (
<AppLink
href={`/projects/${project.id}`}
className="flex items-center gap-3 rounded-lg border px-4 py-3 hover:bg-accent/50 transition-colors"
>
<span className="text-lg shrink-0">{project.icon || "📁"}</span>
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{project.title}</div>
{project.description && (
<div className="text-xs text-muted-foreground truncate mt-0.5">
{project.description}
</div>
)}
</div>
<span className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${statusCfg.badgeBg} ${statusCfg.badgeText}`}>
{statusCfg.label}
</span>
</AppLink>
);
}
function CreateProjectDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const [title, setTitle] = useState("");
const createProject = useCreateProject();
const handleCreate = () => {
if (!title.trim()) return;
createProject.mutate({ title: title.trim() }, {
onSuccess: () => { setTitle(""); onOpenChange(false); },
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create project</DialogTitle>
</DialogHeader>
<Input
placeholder="Project title"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
autoFocus
/>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={!title.trim() || createProject.isPending}>Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ProjectsPage() {
const wsId = useWorkspaceId();
const { data: projects = [], isLoading } = useQuery(projectListOptions(wsId));
const [createOpen, setCreateOpen] = useState(false);
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-6 py-3">
<div className="flex items-center gap-2">
<FolderKanban className="h-4 w-4 text-muted-foreground" />
<h1 className="text-sm font-medium">Projects</h1>
</div>
<Button size="sm" variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1" />
New project
</Button>
</div>
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).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 py-20 text-muted-foreground">
<FolderKanban className="h-10 w-10 mb-3 opacity-40" />
<p className="text-sm">No projects yet</p>
<Button size="sm" variant="outline" className="mt-3" onClick={() => setCreateOpen(true)}>
Create your first project
</Button>
</div>
) : (
<div className="space-y-2">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
</div>
<CreateProjectDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View File

@@ -182,6 +182,17 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
})
})
// 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)
})
})
// Attachments
r.Get("/api/attachments/{id}", h.GetAttachmentByID)
r.Delete("/api/attachments/{id}", h.DeleteAttachment)

View File

@@ -32,6 +32,7 @@ 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"`
@@ -56,6 +57,7 @@ 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),
@@ -217,6 +219,7 @@ func searchRowToIssue(row db.SearchIssuesRow) db.Issue {
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
Number: row.Number,
ProjectID: row.ProjectID,
}
}
@@ -377,6 +380,7 @@ 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"`
AttachmentIDs []string `json:"attachment_ids,omitempty"`
}
@@ -485,6 +489,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
Position: 0,
DueDate: dueDate,
Number: issueNumber,
ProjectID: func() pgtype.UUID { if req.ProjectID != nil { return parseUUID(*req.ProjectID) }; return pgtype.UUID{} }(),
})
if err != nil {
slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
@@ -542,6 +547,7 @@ type UpdateIssueRequest struct {
Position *float64 `json:"position"`
DueDate *string `json:"due_date"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
}
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
@@ -577,6 +583,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
AssigneeID: prevIssue.AssigneeID,
DueDate: prevIssue.DueDate,
ParentIssueID: prevIssue.ParentIssueID,
ProjectID: prevIssue.ProjectID,
}
// COALESCE fields — only set when explicitly provided
@@ -656,6 +663,13 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
params.ParentIssueID = pgtype.UUID{Valid: false} // explicit null = remove parent
}
}
if _, ok := rawFields["project_id"]; ok {
if req.ProjectID != nil {
params.ProjectID = parseUUID(*req.ProjectID)
} else {
params.ProjectID = pgtype.UUID{Valid: false}
}
}
// Enforce agent visibility: private agents can only be assigned by owner/admin.
if req.AssigneeType != nil && *req.AssigneeType == "agent" && req.AssigneeID != nil {

View File

@@ -0,0 +1,236 @@
package handler
import (
"encoding/json"
"io"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
type ProjectResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Title string `json:"title"`
Description *string `json:"description"`
Icon *string `json:"icon"`
Status string `json:"status"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func projectToResponse(p db.Project) ProjectResponse {
return ProjectResponse{
ID: uuidToString(p.ID),
WorkspaceID: uuidToString(p.WorkspaceID),
Title: p.Title,
Description: textToPtr(p.Description),
Icon: textToPtr(p.Icon),
Status: p.Status,
LeadType: textToPtr(p.LeadType),
LeadID: uuidToPtr(p.LeadID),
CreatedAt: timestampToString(p.CreatedAt),
UpdatedAt: timestampToString(p.UpdatedAt),
}
}
type CreateProjectRequest struct {
Title string `json:"title"`
Description *string `json:"description"`
Icon *string `json:"icon"`
Status string `json:"status"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
}
type UpdateProjectRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Icon *string `json:"icon"`
Status *string `json:"status"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
}
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
}
resp := make([]ProjectResponse, len(projects))
for i, p := range projects {
resp[i] = projectToResponse(p)
}
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.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
ID: parseUUID(id), WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
writeJSON(w, http.StatusOK, projectToResponse(project))
}
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.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
workspaceID := resolveWorkspaceID(r)
userID, ok := requireUserID(w, r)
if !ok {
return
}
status := req.Status
if status == "" {
status = "planned"
}
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)
}
project, err := h.Queries.CreateProject(r.Context(), db.CreateProjectParams{
WorkspaceID: parseUUID(workspaceID),
Title: req.Title,
Description: ptrToText(req.Description),
Icon: ptrToText(req.Icon),
Status: status,
LeadType: leadType,
LeadID: leadID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create project")
return
}
resp := projectToResponse(project)
h.publish(protocol.EventProjectCreated, workspaceID, "member", userID, 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.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
ID: parseUUID(id), WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
userID, ok := requireUserID(w, r)
if !ok {
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
}
var rawFields map[string]json.RawMessage
json.Unmarshal(bodyBytes, &rawFields)
params := db.UpdateProjectParams{
ID: prevProject.ID,
Description: prevProject.Description,
Icon: prevProject.Icon,
LeadType: prevProject.LeadType,
LeadID: prevProject.LeadID,
}
if req.Title != nil {
params.Title = pgtype.Text{String: *req.Title, Valid: true}
}
if req.Status != nil {
params.Status = pgtype.Text{String: *req.Status, Valid: true}
}
if _, ok := rawFields["description"]; ok {
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
} else {
params.Description = pgtype.Text{Valid: false}
}
}
if _, ok := rawFields["icon"]; ok {
if req.Icon != nil {
params.Icon = pgtype.Text{String: *req.Icon, Valid: true}
} else {
params.Icon = pgtype.Text{Valid: false}
}
}
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}
}
}
project, err := h.Queries.UpdateProject(r.Context(), params)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update project")
return
}
resp := projectToResponse(project)
h.publish(protocol.EventProjectUpdated, workspaceID, "member", userID, 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)
if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
ID: parseUUID(id), WorkspaceID: parseUUID(workspaceID),
}); err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
if err := h.Queries.DeleteProject(r.Context(), parseUUID(id)); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete project")
return
}
h.publish(protocol.EventProjectDeleted, workspaceID, "member", userID, map[string]any{"project_id": id})
w.WriteHeader(http.StatusNoContent)
}

View File

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

View File

@@ -0,0 +1,20 @@
-- Project table
CREATE TABLE project (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
status TEXT NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned', 'in_progress', 'paused', 'completed', 'cancelled')),
lead_type TEXT CHECK (lead_type IN ('member', 'agent')),
lead_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_project_workspace ON project(workspace_id);
-- 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(project_id);

View File

@@ -42,10 +42,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
parent_issue_id, position, due_date, number, project_id
) VALUES (
$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
$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
`
type CreateIssueParams struct {
@@ -62,6 +62,7 @@ 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) {
@@ -79,6 +80,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
arg.Position,
arg.DueDate,
arg.Number,
arg.ProjectID,
)
var i Issue
err := row.Scan(
@@ -100,6 +102,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}
@@ -114,7 +117,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 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, project_id FROM issue
WHERE id = $1
`
@@ -140,12 +143,13 @@ 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 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, project_id FROM issue
WHERE workspace_id = $1 AND number = $2
`
@@ -176,12 +180,13 @@ 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 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, project_id FROM issue
WHERE id = $1 AND workspace_id = $2
`
@@ -212,12 +217,13 @@ func (q *Queries) GetIssueInWorkspace(ctx context.Context, arg GetIssueInWorkspa
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}
const listChildIssues = `-- name: ListChildIssues :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 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, project_id FROM issue
WHERE parent_issue_id = $1
ORDER BY position ASC, created_at DESC
`
@@ -250,6 +256,7 @@ func (q *Queries) ListChildIssues(ctx context.Context, parentIssueID pgtype.UUID
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
); err != nil {
return nil, err
}
@@ -262,7 +269,7 @@ func (q *Queries) ListChildIssues(ctx context.Context, parentIssueID pgtype.UUID
}
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 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, project_id FROM issue
WHERE workspace_id = $1
AND ($4::text IS NULL OR status = $4)
AND ($5::text IS NULL OR priority = $5)
@@ -315,6 +322,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
); err != nil {
return nil, err
}
@@ -327,7 +335,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue
}
const listOpenIssues = `-- name: ListOpenIssues :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 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, project_id FROM issue
WHERE workspace_id = $1
AND status NOT IN ('done', 'cancelled')
AND ($2::text IS NULL OR priority = $2)
@@ -369,6 +377,7 @@ func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams)
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
); err != nil {
return nil, err
}
@@ -381,7 +390,7 @@ func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams)
}
const searchIssues = `-- name: SearchIssues :many
SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, i.parent_issue_id, i.acceptance_criteria, i.context_refs, i.position, i.due_date, i.created_at, i.updated_at, i.number,
SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, i.parent_issue_id, i.acceptance_criteria, i.context_refs, i.position, i.due_date, i.created_at, i.updated_at, i.number, i.project_id,
COUNT(*) OVER() AS total_count,
CASE
WHEN i.title LIKE '%' || $1 || '%' THEN 'title'
@@ -446,6 +455,7 @@ type SearchIssuesRow struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Number int32 `json:"number"`
ProjectID pgtype.UUID `json:"project_id"`
TotalCount int64 `json:"total_count"`
MatchSource string `json:"match_source"`
MatchedCommentContent interface{} `json:"matched_comment_content"`
@@ -485,6 +495,7 @@ func (q *Queries) SearchIssues(ctx context.Context, arg SearchIssuesParams) ([]S
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.TotalCount,
&i.MatchSource,
&i.MatchedCommentContent,
@@ -510,9 +521,10 @@ UPDATE issue SET
position = COALESCE($8, position),
due_date = $9,
parent_issue_id = $10,
project_id = $11,
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
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
`
type UpdateIssueParams struct {
@@ -526,6 +538,7 @@ type UpdateIssueParams struct {
Position pgtype.Float8 `json:"position"`
DueDate pgtype.Timestamptz `json:"due_date"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
ProjectID pgtype.UUID `json:"project_id"`
}
func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) {
@@ -540,6 +553,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue
arg.Position,
arg.DueDate,
arg.ParentIssueID,
arg.ProjectID,
)
var i Issue
err := row.Scan(
@@ -561,6 +575,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}
@@ -570,7 +585,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
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
`
type UpdateIssueStatusParams struct {
@@ -600,6 +615,7 @@ func (q *Queries) UpdateIssueStatus(ctx context.Context, arg UpdateIssueStatusPa
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
)
return i, err
}

View File

@@ -174,6 +174,7 @@ 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 {
@@ -233,6 +234,19 @@ type PersonalAccessToken struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Project struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
Status string `json:"status"`
LeadType pgtype.Text `json:"lead_type"`
LeadID pgtype.UUID `json:"lead_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type RuntimeUsage struct {
ID pgtype.UUID `json:"id"`
RuntimeID pgtype.UUID `json:"runtime_id"`

View File

@@ -0,0 +1,221 @@
// 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 countIssuesByProject = `-- name: CountIssuesByProject :one
SELECT count(*) FROM issue
WHERE project_id = $1
`
func (q *Queries) CountIssuesByProject(ctx context.Context, projectID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countIssuesByProject, projectID)
var count int64
err := row.Scan(&count)
return count, err
}
const createProject = `-- name: CreateProject :one
INSERT INTO project (
workspace_id, title, description, icon, status,
lead_type, lead_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7
) RETURNING id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at
`
type CreateProjectParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
Status string `json:"status"`
LeadType pgtype.Text `json:"lead_type"`
LeadID pgtype.UUID `json:"lead_id"`
}
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) {
row := q.db.QueryRow(ctx, createProject,
arg.WorkspaceID,
arg.Title,
arg.Description,
arg.Icon,
arg.Status,
arg.LeadType,
arg.LeadID,
)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Icon,
&i.Status,
&i.LeadType,
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteProject = `-- name: DeleteProject :exec
DELETE FROM project WHERE id = $1
`
func (q *Queries) DeleteProject(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteProject, id)
return err
}
const getProject = `-- name: GetProject :one
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at FROM project
WHERE id = $1
`
func (q *Queries) GetProject(ctx context.Context, id pgtype.UUID) (Project, error) {
row := q.db.QueryRow(ctx, getProject, id)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Icon,
&i.Status,
&i.LeadType,
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getProjectInWorkspace = `-- name: GetProjectInWorkspace :one
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at FROM project
WHERE id = $1 AND workspace_id = $2
`
type GetProjectInWorkspaceParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetProjectInWorkspace(ctx context.Context, arg GetProjectInWorkspaceParams) (Project, error) {
row := q.db.QueryRow(ctx, getProjectInWorkspace, arg.ID, arg.WorkspaceID)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Icon,
&i.Status,
&i.LeadType,
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listProjects = `-- name: ListProjects :many
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at FROM project
WHERE workspace_id = $1
AND ($2::text IS NULL OR status = $2)
ORDER BY 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.Title,
&i.Description,
&i.Icon,
&i.Status,
&i.LeadType,
&i.LeadID,
&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 updateProject = `-- name: UpdateProject :one
UPDATE project SET
title = COALESCE($2, title),
description = $3,
icon = $4,
status = COALESCE($5, status),
lead_type = $6,
lead_id = $7,
updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at
`
type UpdateProjectParams struct {
ID pgtype.UUID `json:"id"`
Title pgtype.Text `json:"title"`
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
Status pgtype.Text `json:"status"`
LeadType pgtype.Text `json:"lead_type"`
LeadID pgtype.UUID `json:"lead_id"`
}
func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (Project, error) {
row := q.db.QueryRow(ctx, updateProject,
arg.ID,
arg.Title,
arg.Description,
arg.Icon,
arg.Status,
arg.LeadType,
arg.LeadID,
)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Icon,
&i.Status,
&i.LeadType,
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@@ -19,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
parent_issue_id, position, due_date, number, project_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
) RETURNING *;
-- name: GetIssueByNumber :one
@@ -39,6 +39,7 @@ UPDATE issue SET
position = COALESCE(sqlc.narg('position'), position),
due_date = sqlc.narg('due_date'),
parent_issue_id = sqlc.narg('parent_issue_id'),
project_id = sqlc.narg('project_id'),
updated_at = now()
WHERE id = $1
RETURNING *;

View File

@@ -0,0 +1,40 @@
-- name: ListProjects :many
SELECT * FROM project
WHERE workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
ORDER BY created_at DESC;
-- name: GetProject :one
SELECT * FROM project
WHERE id = $1;
-- name: GetProjectInWorkspace :one
SELECT * FROM project
WHERE id = $1 AND workspace_id = $2;
-- name: CreateProject :one
INSERT INTO project (
workspace_id, title, description, icon, status,
lead_type, lead_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7
) RETURNING *;
-- name: UpdateProject :one
UPDATE project SET
title = COALESCE(sqlc.narg('title'), title),
description = sqlc.narg('description'),
icon = sqlc.narg('icon'),
status = COALESCE(sqlc.narg('status'), status),
lead_type = sqlc.narg('lead_type'),
lead_id = sqlc.narg('lead_id'),
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: DeleteProject :exec
DELETE FROM project WHERE id = $1;
-- name: CountIssuesByProject :one
SELECT count(*) FROM issue
WHERE project_id = $1;

View File

@@ -58,6 +58,11 @@ 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"