mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-24 16:09:19 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/j/6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd6534e4a |
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -305,6 +305,7 @@ vi.mock("@dnd-kit/utilities", () => ({
|
||||
|
||||
const issueDefaults = {
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position: 0,
|
||||
};
|
||||
|
||||
|
||||
13
apps/web/app/(dashboard)/projects/[id]/page.tsx
Normal file
13
apps/web/app/(dashboard)/projects/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
7
apps/web/app/(dashboard)/projects/page.tsx
Normal file
7
apps/web/app/(dashboard)/projects/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
|
||||
export default function Page() {
|
||||
return <ProjectsPage />;
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
20
packages/core/projects/config.ts
Normal file
20
packages/core/projects/config.ts
Normal 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" },
|
||||
};
|
||||
2
packages/core/projects/index.ts
Normal file
2
packages/core/projects/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
|
||||
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";
|
||||
75
packages/core/projects/mutations.ts
Normal file
75
packages/core/projects/mutations.ts
Normal 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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
24
packages/core/projects/queries.ts
Normal file
24
packages/core/projects/queries.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
|
||||
37
packages/core/types/project.ts
Normal file
37
packages/core/types/project.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
3
packages/views/projects/components/index.ts
Normal file
3
packages/views/projects/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ProjectsPage } from "./projects-page";
|
||||
export { ProjectDetail } from "./project-detail";
|
||||
export { ProjectPicker } from "./project-picker";
|
||||
191
packages/views/projects/components/project-detail.tsx
Normal file
191
packages/views/projects/components/project-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
packages/views/projects/components/project-picker.tsx
Normal file
54
packages/views/projects/components/project-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
packages/views/projects/components/projects-page.tsx
Normal file
124
packages/views/projects/components/projects-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
236
server/internal/handler/project.go
Normal file
236
server/internal/handler/project.go
Normal 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)
|
||||
}
|
||||
2
server/migrations/034_projects.down.sql
Normal file
2
server/migrations/034_projects.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE issue DROP COLUMN IF EXISTS project_id;
|
||||
DROP TABLE IF EXISTS project;
|
||||
20
server/migrations/034_projects.up.sql
Normal file
20
server/migrations/034_projects.up.sql
Normal 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);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
221
server/pkg/db/generated/project.sql.go
Normal file
221
server/pkg/db/generated/project.sql.go
Normal 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
|
||||
}
|
||||
@@ -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 *;
|
||||
|
||||
40
server/pkg/db/queries/project.sql
Normal file
40
server/pkg/db/queries/project.sql
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user