Merge pull request #234 from multica-ai/forrestchang/impl-plan

feat: implement full-stack business logic with API, auth, real-time, and tests
This commit is contained in:
Jiayuan Zhang 2026-03-22 11:59:03 +08:00 committed by GitHub
commit 317e87fb97
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 8264 additions and 766 deletions

5
.gitignore vendored
View file

@ -21,6 +21,11 @@ coverage
# Go
server/bin/
server/tmp/
server/migrate
# Test artifacts
test-results/
apps/web/test-results/
# context (agent workspace)
.context

View file

@ -21,23 +21,30 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
## 3. Core Workflow Commands
```bash
# One-click setup & run
make setup # First-time: install deps, start DB, migrate, seed
make start # Start backend + frontend together
make stop # Stop everything
# Frontend
pnpm install
pnpm dev:web # Next.js dev server
pnpm dev:web # Next.js dev server (port 3000)
pnpm build # Build all TS packages
pnpm typecheck # TypeScript check
pnpm test # TS tests
pnpm test # TS tests (Vitest)
# Backend (Go)
make dev # Run Go server with hot-reload
make dev # Run Go server (port 8080)
make daemon # Run local daemon
make test # Go tests
make sqlc # Regenerate sqlc code
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
make seed # Seed sample data
# Infrastructure
docker compose up -d # Start PostgreSQL
docker compose down # Stop PostgreSQL
```
## 4. Coding Rules

View file

@ -1,4 +1,45 @@
.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean
.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop
# ---------- One-click commands ----------
# First-time setup: install deps, start DB, run migrations, seed data
setup:
@echo "==> Installing dependencies..."
pnpm install
@echo "==> Starting PostgreSQL..."
docker compose up -d
@echo "==> Waiting for PostgreSQL to be ready..."
@until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \
sleep 1; \
done
@echo "==> Running migrations..."
cd server && go run ./cmd/migrate up
@echo "==> Seeding data..."
cd server && go run ./cmd/seed
@echo ""
@echo "✓ Setup complete! Run 'make start' to launch the app."
# Start all services (backend + frontend)
start:
@docker compose up -d
@until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \
sleep 1; \
done
@echo "Starting backend and frontend..."
@trap 'kill 0' EXIT; \
(cd server && go run ./cmd/server) & \
pnpm dev:web & \
wait
# Stop all services
stop:
@echo "Stopping services..."
@-lsof -ti:8080 | xargs kill -9 2>/dev/null
@-lsof -ti:3000 | xargs kill -9 2>/dev/null
docker compose down
@echo "✓ All services stopped."
# ---------- Individual commands ----------
# Go server
dev:

View file

@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => "/login",
}));
// Mock auth-context
const mockLogin = vi.fn();
const mockAuthValue = {
user: null,
workspace: null,
members: [],
agents: [],
isLoading: false,
login: mockLogin,
logout: vi.fn(),
refreshMembers: vi.fn(),
refreshAgents: vi.fn(),
getMemberName: () => "Unknown",
getAgentName: () => "Unknown Agent",
getActorName: () => "System",
getActorInitials: () => "XX",
};
vi.mock("../../../lib/auth-context", () => ({
useAuth: () => mockAuthValue,
AuthProvider: ({ children }: { children: React.ReactNode }) => children,
}));
import LoginPage from "./page";
describe("LoginPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders login form with heading, inputs, and button", () => {
render(<LoginPage />);
expect(screen.getByText("Multica")).toBeInTheDocument();
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Email")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Name")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign in" })).toBeInTheDocument();
});
it("does not call login when email is empty", async () => {
const user = userEvent.setup();
render(<LoginPage />);
// The email input has required attribute, so browser validation blocks submit
// Verify login was never called
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(mockLogin).not.toHaveBeenCalled();
});
it("calls login with correct args on submit", async () => {
mockLogin.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
await user.type(screen.getByPlaceholderText("Name"), "Test User");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", "Test User");
});
});
it("calls login with email only when name is empty", async () => {
mockLogin.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", undefined);
});
});
it("shows 'Signing in...' while submitting", async () => {
// Make login hang
mockLogin.mockReturnValueOnce(new Promise(() => {}));
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
expect(screen.getByText("Signing in...")).toBeInTheDocument();
});
});
it("shows error when login fails", async () => {
mockLogin.mockRejectedValueOnce(new Error("Network error"));
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
expect(
screen.getByText("Login failed. Make sure the server is running."),
).toBeInTheDocument();
});
});
});

View file

@ -1,15 +1,65 @@
"use client";
import { useState } from "react";
import { useAuth } from "../../../lib/auth-context";
export default function LoginPage() {
const { login, isLoading } = useAuth();
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
setError("Email is required");
return;
}
setError("");
setSubmitting(true);
try {
await login(email, name || undefined);
} catch (err) {
setError("Login failed. Make sure the server is running.");
setSubmitting(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-sm space-y-6 text-center">
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4 text-center">
<h1 className="text-2xl font-bold">Multica</h1>
<p className="text-muted-foreground">AI-native task management</p>
<button className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground">
Sign in with Google
<div className="space-y-3 text-left">
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
required
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<button
type="submit"
disabled={submitting || isLoading}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
>
{submitting ? "Signing in..." : "Sign in"}
</button>
</div>
</form>
</div>
);
}

View file

@ -1,217 +1,16 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import {
Bot,
Cloud,
Monitor,
Plus,
Wrench,
Blocks,
Zap,
GitBranch,
FileCode,
MessageSquare,
Terminal,
Database,
Globe,
ListTodo,
} from "lucide-react";
import type { AgentStatus, AgentRuntimeMode } from "@multica/types";
// ---------------------------------------------------------------------------
// Types for mock data
// ---------------------------------------------------------------------------
interface AgentSkill {
id: string;
name: string;
description: string;
}
interface AgentTool {
id: string;
name: string;
icon: typeof Terminal;
connected: boolean;
}
interface AgentTask {
id: string;
issueKey: string;
title: string;
status: "working" | "queued";
}
interface MockAgent {
id: string;
name: string;
avatar: string;
runtimeMode: AgentRuntimeMode;
status: AgentStatus;
model: string;
description: string;
maxConcurrentTasks: number;
host?: string;
skills: AgentSkill[];
tools: AgentTool[];
currentTasks: AgentTask[];
completedTasks: number;
createdAt: string;
}
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const MOCK_AGENTS: MockAgent[] = [
{
id: "agent_1",
name: "Claude-1",
avatar: "C1",
runtimeMode: "local",
status: "working",
model: "Claude Sonnet 4",
description:
"General-purpose coding agent for backend development. Specializes in Go API development, database migrations, and test writing.",
maxConcurrentTasks: 2,
host: "jiayuan-macbook",
skills: [
{
id: "sk_1",
name: "Go API Development",
description: "Build RESTful APIs with Chi, implement CRUD handlers, add middleware",
},
{
id: "sk_2",
name: "Database Migrations",
description: "Create and run PostgreSQL migrations, update sqlc queries",
},
{
id: "sk_3",
name: "Test Writing",
description: "Write Go unit and integration tests with testcontainers",
},
],
tools: [
{ id: "t_1", name: "GitHub", icon: GitBranch, connected: true },
{ id: "t_2", name: "Terminal", icon: Terminal, connected: true },
{ id: "t_3", name: "PostgreSQL", icon: Database, connected: true },
{ id: "t_4", name: "Browser", icon: Globe, connected: false },
],
currentTasks: [
{
id: "iss_9",
issueKey: "MUL-9",
title: "Implement issue list API endpoint",
status: "working",
},
{
id: "iss_14",
issueKey: "MUL-14",
title: "Add WebSocket event types for agent status",
status: "queued",
},
],
completedTasks: 12,
createdAt: "2026-03-15T10:00:00Z",
},
{
id: "agent_2",
name: "Codex-1",
avatar: "CX",
runtimeMode: "cloud",
status: "idle",
model: "GPT-5.3 Codex",
description:
"Cloud-hosted coding agent optimized for frontend development. Handles React components, styling, and TypeScript refactoring.",
maxConcurrentTasks: 4,
skills: [
{
id: "sk_4",
name: "React Components",
description: "Build UI components with React, Radix UI, and Tailwind CSS",
},
{
id: "sk_5",
name: "TypeScript Refactoring",
description: "Refactor code for type safety, extract shared types and utilities",
},
],
tools: [
{ id: "t_5", name: "GitHub", icon: GitBranch, connected: true },
{ id: "t_6", name: "Terminal", icon: Terminal, connected: true },
{ id: "t_7", name: "Browser", icon: Globe, connected: true },
{ id: "t_8", name: "Figma", icon: FileCode, connected: false },
],
currentTasks: [],
completedTasks: 8,
createdAt: "2026-03-16T14:00:00Z",
},
{
id: "agent_3",
name: "Review Bot",
avatar: "RB",
runtimeMode: "cloud",
status: "working",
model: "Claude Sonnet 4",
description:
"Automated code reviewer. Analyzes PRs for correctness, security issues, and adherence to team coding standards.",
maxConcurrentTasks: 8,
skills: [
{
id: "sk_6",
name: "Code Review",
description: "Review pull requests for bugs, security issues, and style violations",
},
{
id: "sk_7",
name: "Security Audit",
description: "Check for OWASP top 10 vulnerabilities and insecure patterns",
},
],
tools: [
{ id: "t_9", name: "GitHub", icon: GitBranch, connected: true },
{ id: "t_10", name: "Comments", icon: MessageSquare, connected: true },
],
currentTasks: [
{
id: "iss_pr47",
issueKey: "PR-47",
title: "Review: Add WebSocket reconnection logic",
status: "working",
},
],
completedTasks: 34,
createdAt: "2026-03-14T09:00:00Z",
},
{
id: "agent_4",
name: "Claude-2",
avatar: "C2",
runtimeMode: "local",
status: "offline",
model: "Claude Sonnet 4",
description:
"Secondary local agent on Bohan's machine. Used for documentation and knowledge base tasks.",
maxConcurrentTasks: 1,
host: "bohan-macbook",
skills: [
{
id: "sk_8",
name: "Documentation",
description: "Write and update technical docs, API references, and README files",
},
],
tools: [
{ id: "t_11", name: "GitHub", icon: GitBranch, connected: true },
{ id: "t_12", name: "Terminal", icon: Terminal, connected: true },
],
currentTasks: [],
completedTasks: 5,
createdAt: "2026-03-18T16:00:00Z",
},
];
import type { Agent, AgentStatus } from "@multica/types";
import { api } from "../../../lib/api";
// ---------------------------------------------------------------------------
// Helpers
@ -225,6 +24,15 @@ const statusConfig: Record<AgentStatus, { label: string; color: string; dot: str
offline: { label: "Offline", color: "text-muted-foreground/50", dot: "bg-muted-foreground/40" },
};
function getInitials(name: string): string {
return name
.split(/[\s-]+/)
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
@ -234,7 +42,7 @@ function AgentListItem({
isSelected,
onClick,
}: {
agent: MockAgent;
agent: Agent;
isSelected: boolean;
onClick: () => void;
}) {
@ -247,15 +55,14 @@ function AgentListItem({
isSelected ? "bg-accent" : "hover:bg-accent/50"
}`}
>
{/* Avatar */}
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-xs font-semibold">
{agent.avatar}
{getInitials(agent.name)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{agent.name}</span>
{agent.runtimeMode === "cloud" ? (
{agent.runtime_mode === "cloud" ? (
<Cloud className="h-3 w-3 text-muted-foreground" />
) : (
<Monitor className="h-3 w-3 text-muted-foreground" />
@ -264,38 +71,13 @@ function AgentListItem({
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
<span className={`text-xs ${st.color}`}>{st.label}</span>
{agent.currentTasks.length > 0 && (
<span className="text-xs text-muted-foreground">
· {agent.currentTasks.length} task{agent.currentTasks.length > 1 ? "s" : ""}
</span>
)}
</div>
</div>
</button>
);
}
function SectionHeader({
icon: Icon,
title,
count,
}: {
icon: typeof Wrench;
title: string;
count?: number;
}) {
return (
<div className="flex items-center gap-2 mb-3">
<Icon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">{title}</h3>
{count !== undefined && (
<span className="text-xs text-muted-foreground">({count})</span>
)}
</div>
);
}
function AgentDetail({ agent }: { agent: MockAgent }) {
function AgentDetail({ agent }: { agent: Agent }) {
const st = statusConfig[agent.status];
return (
@ -303,7 +85,7 @@ function AgentDetail({ agent }: { agent: MockAgent }) {
{/* Header */}
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-muted text-sm font-bold">
{agent.avatar}
{getInitials(agent.name)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
@ -313,7 +95,9 @@ function AgentDetail({ agent }: { agent: MockAgent }) {
{st.label}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">{agent.description}</p>
<p className="mt-1 text-sm text-muted-foreground">
{agent.runtime_mode === "cloud" ? "Cloud-hosted" : "Local"} agent
</p>
</div>
</div>
@ -322,101 +106,53 @@ function AgentDetail({ agent }: { agent: MockAgent }) {
<div>
<div className="text-xs text-muted-foreground">Runtime</div>
<div className="mt-1 flex items-center gap-1.5 text-sm font-medium">
{agent.runtimeMode === "cloud" ? (
{agent.runtime_mode === "cloud" ? (
<Cloud className="h-3.5 w-3.5" />
) : (
<Monitor className="h-3.5 w-3.5" />
)}
{agent.runtimeMode === "cloud" ? "Cloud" : "Local"}
{agent.host && (
<span className="text-muted-foreground font-normal">({agent.host})</span>
)}
{agent.runtime_mode === "cloud" ? "Cloud" : "Local"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Model</div>
<div className="mt-1 text-sm font-medium">{agent.model}</div>
<div className="text-xs text-muted-foreground">Visibility</div>
<div className="mt-1 text-sm font-medium capitalize">{agent.visibility}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Concurrency</div>
<div className="text-xs text-muted-foreground">Max Concurrent Tasks</div>
<div className="mt-1 text-sm font-medium">{agent.max_concurrent_tasks}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Created</div>
<div className="mt-1 text-sm font-medium">
{agent.currentTasks.filter((t) => t.status === "working").length} / {agent.maxConcurrentTasks} slots
{new Date(agent.created_at).toLocaleDateString()}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Completed Tasks</div>
<div className="mt-1 text-sm font-medium">{agent.completedTasks}</div>
</div>
</div>
{/* Skills */}
{/* Status */}
<div>
<SectionHeader icon={Zap} title="Skills" count={agent.skills.length} />
<div className="space-y-2">
{agent.skills.map((skill) => (
<div
key={skill.id}
className="rounded-lg border px-4 py-3"
>
<div className="text-sm font-medium">{skill.name}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{skill.description}
</div>
</div>
))}
<div className="flex items-center gap-2 mb-3">
<Zap className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Status</h3>
</div>
</div>
{/* Connected Tools */}
<div>
<SectionHeader icon={Blocks} title="Connected Tools" count={agent.tools.length} />
<div className="grid grid-cols-2 gap-2">
{agent.tools.map((tool) => (
<div
key={tool.id}
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
tool.connected ? "" : "opacity-50"
}`}
>
<tool.icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-sm">{tool.name}</span>
{tool.connected ? (
<span className="ml-auto text-xs text-green-600">Connected</span>
) : (
<span className="ml-auto text-xs text-muted-foreground">Not set up</span>
)}
</div>
))}
</div>
</div>
{/* Current Tasks */}
<div>
<SectionHeader icon={ListTodo} title="Current Tasks" count={agent.currentTasks.length} />
{agent.currentTasks.length > 0 ? (
<div className="space-y-2">
{agent.currentTasks.map((task) => (
<div
key={task.id}
className="flex items-center gap-3 rounded-lg border px-4 py-3"
>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-mono font-medium">
{task.issueKey}
</span>
<span className="min-w-0 flex-1 truncate text-sm">{task.title}</span>
<span
className={`shrink-0 text-xs ${
task.status === "working" ? "text-green-600" : "text-muted-foreground"
}`}
>
{task.status === "working" ? "Working" : "Queued"}
</span>
</div>
))}
<div className="rounded-lg border px-4 py-3">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${st.dot}`} />
<span className={`text-sm font-medium ${st.color}`}>{st.label}</span>
</div>
) : (
<p className="text-sm text-muted-foreground">No active tasks</p>
)}
</div>
</div>
{/* Tasks placeholder */}
<div>
<div className="flex items-center gap-2 mb-3">
<ListTodo className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Tasks</h3>
</div>
<p className="text-sm text-muted-foreground">
Task queue will be shown here when agents are assigned issues.
</p>
</div>
</div>
);
@ -427,8 +163,30 @@ function AgentDetail({ agent }: { agent: MockAgent }) {
// ---------------------------------------------------------------------------
export default function AgentsPage() {
const [selectedId, setSelectedId] = useState<string>(MOCK_AGENTS[0]?.id ?? "");
const selected = MOCK_AGENTS.find((a) => a.id === selectedId) ?? null;
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedId, setSelectedId] = useState<string>("");
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.listAgents()
.then((a) => {
setAgents(a);
if (a.length > 0) setSelectedId(a[0]!.id);
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const selected = agents.find((a) => a.id === selectedId) ?? null;
if (loading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
);
}
return (
<div className="flex h-full">
@ -441,7 +199,7 @@ export default function AgentsPage() {
</button>
</div>
<div className="divide-y">
{MOCK_AGENTS.map((agent) => (
{agents.map((agent) => (
<AgentListItem
key={agent.id}
agent={agent}

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import {
AlertCircle,
Bot,
@ -11,111 +11,7 @@ import {
ArrowRightLeft,
} from "lucide-react";
import type { InboxItem, InboxItemType, InboxSeverity } from "@multica/types";
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const MOCK_INBOX_ITEMS: InboxItem[] = [
{
id: "inb_1",
workspace_id: "ws_1",
recipient_type: "member",
recipient_id: "usr_1",
type: "agent_blocked",
severity: "action_required",
issue_id: "iss_12",
title: "Agent Claude-1 is blocked on MUL-12",
body: "I need clarification on the authentication flow. The current OAuth implementation uses PKCE, but the design doc references a session-based approach. Which one should I follow?\n\nSpecifically:\n1. Should we keep the PKCE flow for the SPA?\n2. Is the session cookie approach only for the server-rendered pages?\n3. Should I implement both and let the client decide?\n\nBlocked on this decision before I can continue with the login page implementation.",
read: false,
archived: false,
created_at: "2026-03-21T05:32:00Z",
},
{
id: "inb_2",
workspace_id: "ws_1",
recipient_type: "member",
recipient_id: "usr_1",
type: "review_requested",
severity: "action_required",
issue_id: "iss_8",
title: "PR #47: Add WebSocket reconnection logic",
body: "Agent Codex-1 has submitted a pull request for review.\n\n**Changes:**\n- Added exponential backoff for WebSocket reconnection\n- Max retry attempts configurable via env var\n- Added connection state to the store\n- Unit tests for reconnection logic\n\n**Files changed:** 6 files (+284, -12)\n\nThe agent notes that it chose exponential backoff over linear retry because of the bursty reconnection pattern observed in the daemon logs.",
read: false,
archived: false,
created_at: "2026-03-21T04:15:00Z",
},
{
id: "inb_3",
workspace_id: "ws_1",
recipient_type: "member",
recipient_id: "usr_1",
type: "issue_assigned",
severity: "action_required",
issue_id: "iss_15",
title: "New issue assigned: Design the agent config UI",
body: "You've been assigned to MUL-15: Design the agent config UI.\n\nPriority: High\nCreated by: Bohan\n\nDescription:\nWe need a configuration panel where users can set up their local agents — select runtime type, set concurrency limits, and manage API keys. This should live in the Settings page for now.",
read: true,
archived: false,
created_at: "2026-03-21T02:40:00Z",
},
{
id: "inb_4",
workspace_id: "ws_1",
recipient_type: "member",
recipient_id: "usr_1",
type: "agent_completed",
severity: "attention",
issue_id: "iss_6",
title: "Agent Claude-1 completed MUL-6: API error handling",
body: "The task has been completed and all acceptance criteria passed:\n\n✅ Standardized error response format\n✅ Added error codes enum\n✅ Middleware catches panics and returns 500\n✅ All existing tests still pass\n✅ 4 new test cases added\n\nPR #45 has been created and CI is green. Ready for your review when convenient.",
read: false,
archived: false,
created_at: "2026-03-20T22:10:00Z",
},
{
id: "inb_5",
workspace_id: "ws_1",
recipient_type: "member",
recipient_id: "usr_1",
type: "mentioned",
severity: "attention",
issue_id: "iss_10",
title: "Yuzhen mentioned you in MUL-10",
body: "@jiayuan Can you take a look at the database schema for the knowledge base? I want to make sure the vector embeddings table is set up correctly before we start indexing.\n\nI'm thinking we should use pgvector with HNSW index for the similarity search. Thoughts?",
read: true,
archived: false,
created_at: "2026-03-20T18:30:00Z",
},
{
id: "inb_6",
workspace_id: "ws_1",
recipient_type: "member",
recipient_id: "usr_1",
type: "status_change",
severity: "info",
issue_id: "iss_3",
title: "MUL-3 moved to Done",
body: "Issue \"Set up CI/CD pipeline\" has been moved from In Review to Done by Bohan.\n\nThe GitHub Actions workflow is now running on every push to main. Build, test, and lint checks are all configured.",
read: true,
archived: false,
created_at: "2026-03-20T15:00:00Z",
},
{
id: "inb_7",
workspace_id: "ws_1",
recipient_type: "member",
recipient_id: "usr_1",
type: "status_change",
severity: "info",
issue_id: "iss_9",
title: "MUL-9 moved to In Progress",
body: "Agent Codex-1 has started working on \"Implement issue list API endpoint\".\n\nEstimated approach:\n1. Add sqlc queries for listing/filtering issues\n2. Implement Chi handler with pagination\n3. Add sorting by priority, status, created_at\n4. Write integration tests",
read: true,
archived: false,
created_at: "2026-03-20T12:45:00Z",
},
];
import { api } from "../../../lib/api";
// ---------------------------------------------------------------------------
// Helpers
@ -165,16 +61,14 @@ function InboxListItem({
isSelected: boolean;
onClick: () => void;
}) {
const Icon = typeIcons[item.type];
const Icon = typeIcons[item.type] ?? CircleDot;
const colorClass = severityColors[item.severity];
return (
<button
onClick={onClick}
className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors ${
isSelected
? "bg-accent"
: "hover:bg-accent/50"
isSelected ? "bg-accent" : "hover:bg-accent/50"
} ${!item.read ? "font-medium" : ""}`}
>
<Icon className={`mt-0.5 h-4 w-4 shrink-0 ${colorClass}`} />
@ -185,12 +79,12 @@ function InboxListItem({
{timeAgo(item.created_at)}
</span>
</div>
{item.type === "agent_blocked" || item.type === "review_requested" ? (
{(item.type === "agent_blocked" || item.type === "review_requested") && (
<div className="mt-0.5 flex items-center gap-1.5">
<Bot className="h-3 w-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Agent action</span>
</div>
) : null}
)}
</div>
{!item.read && (
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />
@ -199,8 +93,14 @@ function InboxListItem({
);
}
function InboxDetail({ item }: { item: InboxItem }) {
const Icon = typeIcons[item.type];
function InboxDetail({
item,
onMarkRead,
}: {
item: InboxItem;
onMarkRead: (id: string) => void;
}) {
const Icon = typeIcons[item.type] ?? CircleDot;
const colorClass = severityColors[item.severity];
const severityLabel: Record<InboxSeverity, string> = {
@ -220,14 +120,16 @@ function InboxDetail({ item }: { item: InboxItem }) {
<span className={colorClass}>{severityLabel[item.severity]}</span>
<span>·</span>
<span>{timeAgo(item.created_at)}</span>
{item.issue_id && (
<>
<span>·</span>
<span>{item.issue_id}</span>
</>
)}
</div>
</div>
{!item.read && (
<button
onClick={() => onMarkRead(item.id)}
className="shrink-0 rounded-md border px-2 py-1 text-xs hover:bg-accent"
>
Mark read
</button>
)}
</div>
{/* Body */}
@ -245,14 +147,47 @@ function InboxDetail({ item }: { item: InboxItem }) {
// ---------------------------------------------------------------------------
export default function InboxPage() {
const sorted = [...MOCK_INBOX_ITEMS].sort(
(a, b) =>
severityOrder[a.severity] - severityOrder[b.severity] ||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
const [items, setItems] = useState<InboxItem[]>([]);
const [selectedId, setSelectedId] = useState<string>("");
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string>(sorted[0]?.id ?? "");
const selected = sorted.find((i) => i.id === selectedId) ?? null;
useEffect(() => {
api
.listInbox()
.then((data) => {
const sorted = [...data].sort(
(a, b) =>
severityOrder[a.severity] - severityOrder[b.severity] ||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
setItems(sorted);
if (sorted.length > 0) setSelectedId(sorted[0]!.id);
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const handleMarkRead = async (id: string) => {
try {
await api.markInboxRead(id);
setItems((prev) =>
prev.map((i) => (i.id === id ? { ...i, read: true } : i))
);
} catch (err) {
console.error("Failed to mark read:", err);
}
};
const selected = items.find((i) => i.id === selectedId) ?? null;
const unreadCount = items.filter((i) => !i.read).length;
if (loading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
);
}
return (
<div className="flex h-full">
@ -260,29 +195,39 @@ export default function InboxPage() {
<div className="w-80 shrink-0 overflow-y-auto border-r">
<div className="flex h-12 items-center border-b px-4">
<h1 className="text-sm font-semibold">Inbox</h1>
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
{sorted.filter((i) => !i.read).length}
</span>
</div>
<div className="divide-y">
{sorted.map((item) => (
<InboxListItem
key={item.id}
item={item}
isSelected={item.id === selectedId}
onClick={() => setSelectedId(item.id)}
/>
))}
{unreadCount > 0 && (
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
{unreadCount}
</span>
)}
</div>
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-sm text-muted-foreground">
<p>No notifications yet</p>
</div>
) : (
<div className="divide-y">
{items.map((item) => (
<InboxListItem
key={item.id}
item={item}
isSelected={item.id === selectedId}
onClick={() => setSelectedId(item.id)}
/>
))}
</div>
)}
</div>
{/* Right column — detail */}
<div className="flex-1 overflow-y-auto">
{selected ? (
<InboxDetail item={selected} />
<InboxDetail item={selected} onMarkRead={handleMarkRead} />
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
Select an item to view details
{items.length === 0
? "Your inbox is empty"
: "Select an item to view details"}
</div>
)}
</div>

View file

@ -0,0 +1,246 @@
import { Suspense } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Issue, Comment } from "@multica/types";
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => "/issues/issue-1",
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({
children,
href,
...props
}: {
children: React.ReactNode;
href: string;
[key: string]: any;
}) => (
<a href={href} {...props}>
{children}
</a>
),
}));
// Mock auth context
vi.mock("../../../../lib/auth-context", () => ({
useAuth: () => ({
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
workspace: { id: "ws-1", name: "Test WS" },
members: [
{ user_id: "user-1", name: "Test User", email: "test@multica.ai" },
],
agents: [{ id: "agent-1", name: "Claude Agent" }],
isLoading: false,
getActorName: (type: string, id: string) => {
if (type === "member" && id === "user-1") return "Test User";
if (type === "agent" && id === "agent-1") return "Claude Agent";
return "Unknown";
},
getActorInitials: (type: string, id: string) => {
if (type === "member") return "TU";
if (type === "agent") return "CA";
return "??";
},
}),
}));
// Mock api
const mockGetIssue = vi.hoisted(() => vi.fn());
const mockListComments = vi.hoisted(() => vi.fn());
const mockCreateComment = vi.hoisted(() => vi.fn());
vi.mock("../../../../lib/api", () => ({
api: {
getIssue: (...args: any[]) => mockGetIssue(...args),
listComments: (...args: any[]) => mockListComments(...args),
createComment: (...args: any[]) => mockCreateComment(...args),
},
}));
const mockIssue: Issue = {
id: "issue-1",
workspace_id: "ws-1",
title: "Implement authentication",
description: "Add JWT auth to the backend",
status: "in_progress",
priority: "high",
assignee_type: "member",
assignee_id: "user-1",
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
acceptance_criteria: [],
context_refs: [],
repository: null,
position: 0,
due_date: "2026-06-01T00:00:00Z",
created_at: "2026-01-15T00:00:00Z",
updated_at: "2026-01-20T00:00:00Z",
};
const mockComments: Comment[] = [
{
id: "comment-1",
issue_id: "issue-1",
content: "Started working on this",
type: "comment",
author_type: "member",
author_id: "user-1",
created_at: "2026-01-16T00:00:00Z",
updated_at: "2026-01-16T00:00:00Z",
},
{
id: "comment-2",
issue_id: "issue-1",
content: "I can help with this",
type: "comment",
author_type: "agent",
author_id: "agent-1",
created_at: "2026-01-17T00:00:00Z",
updated_at: "2026-01-17T00:00:00Z",
},
];
import IssueDetailPage from "./page";
// React 19 use(Promise) needs the promise to resolve within act + Suspense
async function renderPage(id = "issue-1") {
let result: ReturnType<typeof render>;
await act(async () => {
result = render(
<Suspense fallback={<div>Suspense loading...</div>}>
<IssueDetailPage params={Promise.resolve({ id })} />
</Suspense>,
);
});
return result!;
}
describe("IssueDetailPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders issue details after loading", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListComments.mockResolvedValueOnce(mockComments);
await renderPage();
await waitFor(() => {
expect(
screen.getByText("Implement authentication"),
).toBeInTheDocument();
});
expect(
screen.getByText("Add JWT auth to the backend"),
).toBeInTheDocument();
});
it("renders issue properties sidebar", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListComments.mockResolvedValueOnce(mockComments);
await renderPage();
await waitFor(() => {
expect(screen.getByText("Properties")).toBeInTheDocument();
});
expect(screen.getByText("In Progress")).toBeInTheDocument();
expect(screen.getByText("High")).toBeInTheDocument();
});
it("renders comments", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListComments.mockResolvedValueOnce(mockComments);
await renderPage();
await waitFor(() => {
expect(
screen.getByText("Started working on this"),
).toBeInTheDocument();
});
expect(screen.getByText("I can help with this")).toBeInTheDocument();
expect(screen.getByText("Activity")).toBeInTheDocument();
});
it("shows 'Issue not found' for missing issue", async () => {
mockGetIssue.mockRejectedValueOnce(new Error("Not found"));
mockListComments.mockRejectedValueOnce(new Error("Not found"));
await renderPage("nonexistent-id");
await waitFor(() => {
expect(screen.getByText("Issue not found")).toBeInTheDocument();
});
});
it("submits a new comment", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListComments.mockResolvedValueOnce(mockComments);
const newComment: Comment = {
id: "comment-3",
issue_id: "issue-1",
content: "New test comment",
type: "comment",
author_type: "member",
author_id: "user-1",
created_at: "2026-01-18T00:00:00Z",
updated_at: "2026-01-18T00:00:00Z",
};
mockCreateComment.mockResolvedValueOnce(newComment);
const user = userEvent.setup();
await renderPage();
await waitFor(() => {
expect(
screen.getByPlaceholderText("Leave a comment..."),
).toBeInTheDocument();
});
await user.type(
screen.getByPlaceholderText("Leave a comment..."),
"New test comment",
);
const form = screen
.getByPlaceholderText("Leave a comment...")
.closest("form")!;
const submitBtn = form.querySelector(
'button[type="submit"]',
) as HTMLElement;
await user.click(submitBtn);
await waitFor(() => {
expect(mockCreateComment).toHaveBeenCalledWith(
"issue-1",
"New test comment",
);
});
await waitFor(() => {
expect(screen.getByText("New test comment")).toBeInTheDocument();
});
});
it("renders breadcrumb navigation", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListComments.mockResolvedValueOnce(mockComments);
await renderPage();
await waitFor(() => {
expect(screen.getByText("Issues")).toBeInTheDocument();
});
const issuesLink = screen.getByText("Issues");
expect(issuesLink.closest("a")).toHaveAttribute("href", "/issues");
});
});

View file

@ -1,21 +1,17 @@
"use client";
import { use } from "react";
import { use, useState, useEffect } from "react";
import Link from "next/link";
import {
Bot,
Calendar,
ChevronRight,
User,
MessageSquare,
Send,
} from "lucide-react";
import {
MOCK_ISSUES,
STATUS_CONFIG,
PRIORITY_CONFIG,
} from "../_data/mock";
import type { MockAssignee } from "../_data/mock";
import type { Issue, Comment } from "@multica/types";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "../_data/mock";
import { StatusIcon, PriorityIcon } from "../page";
import { api } from "../../../../lib/api";
import { useAuth } from "../../../../lib/auth-context";
// ---------------------------------------------------------------------------
// Helpers
@ -32,16 +28,8 @@ function timeAgo(dateStr: string): string {
return `${days}d ago`;
}
function formatDate(date: string | null): string {
function shortDate(date: string | null): string {
if (!date) return "—";
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function shortDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
@ -52,14 +40,19 @@ function shortDate(date: string): string {
// Avatar
// ---------------------------------------------------------------------------
function Avatar({
person,
function ActorAvatar({
actorType,
actorId,
size = 20,
}: {
person: MockAssignee;
actorType: string;
actorId: string;
size?: number;
}) {
const isAgent = person.type === "agent";
const { getActorName, getActorInitials } = useAuth();
const name = getActorName(actorType, actorId);
const initials = getActorInitials(actorType, actorId);
const isAgent = actorType === "agent";
return (
<div
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium ${
@ -68,15 +61,19 @@ function Avatar({
: "bg-muted text-muted-foreground"
}`}
style={{ width: size, height: size, fontSize: size * 0.45 }}
title={person.name}
title={name}
>
{isAgent ? <Bot style={{ width: size * 0.55, height: size * 0.55 }} /> : person.avatar.charAt(0)}
{isAgent ? (
<Bot style={{ width: size * 0.55, height: size * 0.55 }} />
) : (
initials
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Property row (Linear-style: label left, clickable value right)
// Property row
// ---------------------------------------------------------------------------
function PropRow({
@ -106,7 +103,45 @@ export default function IssueDetailPage({
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const issue = MOCK_ISSUES.find((i) => i.id === id);
const { getActorName } = useAuth();
const [issue, setIssue] = useState<Issue | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [commentText, setCommentText] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
Promise.all([api.getIssue(id), api.listComments(id)])
.then(([iss, cmts]) => {
setIssue(iss);
setComments(cmts);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [id]);
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!commentText.trim() || submitting) return;
setSubmitting(true);
try {
const comment = await api.createComment(id, commentText.trim());
setComments((prev) => [...prev, comment]);
setCommentText("");
} catch (err) {
console.error("Failed to create comment:", err);
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
);
}
if (!issue) {
return (
@ -119,31 +154,11 @@ export default function IssueDetailPage({
const statusCfg = STATUS_CONFIG[issue.status];
const priorityCfg = PRIORITY_CONFIG[issue.priority];
const isOverdue =
issue.dueDate && new Date(issue.dueDate) < new Date() && issue.status !== "done";
// Merge activity + comments into timeline
const timeline = [
...issue.activity.map((a) => ({
id: a.id,
kind: "activity" as const,
actor: a.actor,
content: a.action,
createdAt: a.createdAt,
})),
...issue.comments.map((c) => ({
id: c.id,
kind: "comment" as const,
actor: c.author,
content: c.body,
createdAt: c.createdAt,
})),
].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
issue.due_date && new Date(issue.due_date) < new Date() && issue.status !== "done";
return (
<div className="flex h-full">
{/* ================================================================
LEFT: Content area
================================================================ */}
{/* LEFT: Content area */}
<div className="flex-1 overflow-y-auto">
{/* Header bar */}
<div className="sticky top-0 z-10 flex h-11 items-center gap-1.5 border-b bg-background px-6 text-[13px]">
@ -154,90 +169,76 @@ export default function IssueDetailPage({
Issues
</Link>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<span className="truncate text-muted-foreground">{issue.key}</span>
<span className="truncate text-muted-foreground">{issue.id.slice(0, 8)}</span>
</div>
{/* Content */}
<div className="mx-auto w-full max-w-3xl px-8 py-8">
{/* Issue key */}
<div className="mb-1 text-[13px] text-muted-foreground">{issue.key}</div>
<div className="mb-1 text-[13px] text-muted-foreground">{issue.id.slice(0, 8)}</div>
{/* Title */}
<h1 className="text-xl font-semibold leading-snug tracking-tight">
{issue.title}
</h1>
{/* Description */}
{issue.description && (
<div className="mt-5 text-[14px] leading-[1.7] text-foreground/85 whitespace-pre-wrap">
{issue.description}
</div>
)}
{/* Separator */}
<div className="my-8 border-t" />
{/* Activity */}
{/* Activity / Comments */}
<div>
<h2 className="text-[13px] font-medium">Activity</h2>
<div className="mt-4">
{timeline.map((entry, idx) =>
entry.kind === "comment" ? (
/* ---- Comment ---- */
<div key={entry.id} className="relative py-3">
<div className="flex items-center gap-2.5">
<Avatar person={entry.actor} size={28} />
<span className="text-[13px] font-medium">
{entry.actor.name}
</span>
<span className="text-[12px] text-muted-foreground">
{timeAgo(entry.createdAt)}
</span>
</div>
<div className="mt-2 pl-[38px] text-[13px] leading-[1.6] text-foreground/85 whitespace-pre-wrap">
{entry.content}
</div>
</div>
) : (
/* ---- Status change ---- */
<div
key={entry.id}
className="flex items-center gap-2.5 py-1.5 text-[12px] text-muted-foreground"
>
<span className="flex h-[28px] w-[28px] shrink-0 items-center justify-center">
<span className="h-[5px] w-[5px] rounded-full bg-muted-foreground/40" />
{comments.map((comment) => (
<div key={comment.id} className="relative py-3">
<div className="flex items-center gap-2.5">
<ActorAvatar
actorType={comment.author_type}
actorId={comment.author_id}
size={28}
/>
<span className="text-[13px] font-medium">
{getActorName(comment.author_type, comment.author_id)}
</span>
<span>
<span className="text-foreground/70">
{entry.actor.name}
</span>{" "}
{entry.content}
<span className="text-[12px] text-muted-foreground">
{timeAgo(comment.created_at)}
</span>
<span className="ml-auto shrink-0">{timeAgo(entry.createdAt)}</span>
</div>
)
)}
<div className="mt-2 pl-[38px] text-[13px] leading-[1.6] text-foreground/85 whitespace-pre-wrap">
{comment.content}
</div>
</div>
))}
</div>
{/* Comment input */}
<div className="mt-2 border-t pt-4">
<div className="flex items-center gap-2.5 cursor-text text-[13px] text-muted-foreground">
<span className="flex h-[28px] w-[28px] shrink-0 items-center justify-center">
<span className="h-[5px] w-[5px] rounded-full bg-muted-foreground/30" />
</span>
<span className="transition-colors hover:text-foreground/50">
Leave a comment...
</span>
<form onSubmit={handleSubmitComment} className="mt-2 border-t pt-4">
<div className="flex items-center gap-2">
<input
type="text"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Leave a comment..."
className="flex-1 rounded-md border bg-background px-3 py-2 text-[13px] placeholder:text-muted-foreground"
/>
<button
type="submit"
disabled={!commentText.trim() || submitting}
className="rounded-md bg-primary p-2 text-primary-foreground disabled:opacity-50"
>
<Send className="h-3.5 w-3.5" />
</button>
</div>
</div>
</form>
</div>
</div>
</div>
{/* ================================================================
RIGHT: Properties sidebar
================================================================ */}
{/* RIGHT: Properties sidebar */}
<div className="w-60 shrink-0 overflow-y-auto border-l">
<div className="p-4">
<div className="mb-2 text-[12px] font-medium text-muted-foreground">
@ -256,10 +257,14 @@ export default function IssueDetailPage({
</PropRow>
<PropRow label="Assignee">
{issue.assignee ? (
{issue.assignee_type && issue.assignee_id ? (
<>
<Avatar person={issue.assignee} size={18} />
<span>{issue.assignee.name}</span>
<ActorAvatar
actorType={issue.assignee_type}
actorId={issue.assignee_id}
size={18}
/>
<span>{getActorName(issue.assignee_type, issue.assignee_id)}</span>
</>
) : (
<span className="text-muted-foreground">Unassigned</span>
@ -267,9 +272,9 @@ export default function IssueDetailPage({
</PropRow>
<PropRow label="Due date">
{issue.dueDate ? (
{issue.due_date ? (
<span className={isOverdue ? "text-red-500" : ""}>
{shortDate(issue.dueDate)}
{shortDate(issue.due_date)}
</span>
) : (
<span className="text-muted-foreground">None</span>
@ -277,17 +282,21 @@ export default function IssueDetailPage({
</PropRow>
<PropRow label="Created by">
<Avatar person={issue.creator} size={18} />
<span>{issue.creator.name}</span>
<ActorAvatar
actorType={issue.creator_type}
actorId={issue.creator_id}
size={18}
/>
<span>{getActorName(issue.creator_type, issue.creator_id)}</span>
</PropRow>
</div>
<div className="mt-4 border-t pt-3 space-y-0.5">
<PropRow label="Created">
<span className="text-muted-foreground">{shortDate(issue.createdAt)}</span>
<span className="text-muted-foreground">{shortDate(issue.created_at)}</span>
</PropRow>
<PropRow label="Updated">
<span className="text-muted-foreground">{shortDate(issue.updatedAt)}</span>
<span className="text-muted-foreground">{shortDate(issue.updated_at)}</span>
</PropRow>
</div>
</div>

View file

@ -95,7 +95,12 @@ export const PRIORITY_CONFIG: Record<
// Mock Issues
// ---------------------------------------------------------------------------
const { jiayuan, bohan, yuzhen, claude1, codex1, reviewBot } = PEOPLE;
const jiayuan = PEOPLE["jiayuan"]!;
const bohan = PEOPLE["bohan"]!;
const yuzhen = PEOPLE["yuzhen"]!;
const claude1 = PEOPLE["claude1"]!;
const codex1 = PEOPLE["codex1"]!;
const reviewBot = PEOPLE["reviewBot"]!;
export const MOCK_ISSUES: MockIssue[] = [
// ---- Backlog ----

View file

@ -0,0 +1,302 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Issue, ListIssuesResponse } from "@multica/types";
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => "/issues",
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({
children,
href,
...props
}: {
children: React.ReactNode;
href: string;
[key: string]: any;
}) => (
<a href={href} {...props}>
{children}
</a>
),
}));
// Mock auth context
vi.mock("../../../lib/auth-context", () => ({
useAuth: () => ({
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
workspace: { id: "ws-1", name: "Test WS" },
members: [
{ user_id: "user-1", name: "Test User", email: "test@multica.ai" },
],
agents: [{ id: "agent-1", name: "Claude Agent" }],
isLoading: false,
getActorName: (type: string, id: string) =>
type === "member" ? "Test User" : "Claude Agent",
getActorInitials: () => "TU",
}),
}));
// Mock WebSocket context
vi.mock("../../../lib/ws-context", () => ({
useWSEvent: vi.fn(),
useWS: () => ({ subscribe: vi.fn(() => () => {}) }),
WSProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock api
const mockListIssues = vi.fn();
const mockCreateIssue = vi.fn();
const mockUpdateIssue = vi.fn();
vi.mock("../../../lib/api", () => ({
api: {
listIssues: (...args: any[]) => mockListIssues(...args),
createIssue: (...args: any[]) => mockCreateIssue(...args),
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
},
}));
const issueDefaults = {
parent_issue_id: null,
acceptance_criteria: [],
context_refs: [],
repository: null,
position: 0,
};
const mockIssues: Issue[] = [
{
...issueDefaults,
id: "issue-1",
workspace_id: "ws-1",
title: "Implement auth",
description: "Add JWT authentication",
status: "todo",
priority: "high",
assignee_type: "member",
assignee_id: "user-1",
creator_type: "member",
creator_id: "user-1",
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
...issueDefaults,
id: "issue-2",
workspace_id: "ws-1",
title: "Design landing page",
description: null,
status: "in_progress",
priority: "medium",
assignee_type: "agent",
assignee_id: "agent-1",
creator_type: "member",
creator_id: "user-1",
due_date: "2026-02-01T00:00:00Z",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
...issueDefaults,
id: "issue-3",
workspace_id: "ws-1",
title: "Write tests",
description: null,
status: "backlog",
priority: "low",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
];
import IssuesPage from "./page";
describe("IssuesPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows loading state initially", () => {
mockListIssues.mockReturnValueOnce(new Promise(() => {}));
render(<IssuesPage />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("renders issues in board view after loading", async () => {
mockListIssues.mockResolvedValueOnce({
issues: mockIssues,
total: 3,
} as ListIssuesResponse);
render(<IssuesPage />);
await waitFor(() => {
expect(screen.getByText("Implement auth")).toBeInTheDocument();
});
expect(screen.getByText("Design landing page")).toBeInTheDocument();
expect(screen.getByText("Write tests")).toBeInTheDocument();
expect(screen.getByText("All Issues")).toBeInTheDocument();
});
it("renders board columns", async () => {
mockListIssues.mockResolvedValueOnce({
issues: mockIssues,
total: 3,
} as ListIssuesResponse);
render(<IssuesPage />);
await waitFor(() => {
expect(screen.getByText("Backlog")).toBeInTheDocument();
});
expect(screen.getByText("Todo")).toBeInTheDocument();
expect(screen.getByText("In Progress")).toBeInTheDocument();
expect(screen.getByText("In Review")).toBeInTheDocument();
expect(screen.getByText("Done")).toBeInTheDocument();
});
it("switches to list view", async () => {
mockListIssues.mockResolvedValueOnce({
issues: mockIssues,
total: 3,
} as ListIssuesResponse);
const user = userEvent.setup();
render(<IssuesPage />);
await waitFor(() => {
expect(screen.getByText("Implement auth")).toBeInTheDocument();
});
// Find the List button and click it
const listButton = screen.getByText("List");
await user.click(listButton);
// Issues should still be visible
expect(screen.getByText("Implement auth")).toBeInTheDocument();
expect(screen.getByText("Design landing page")).toBeInTheDocument();
});
it("shows 'New Issue' button and opens create form", async () => {
mockListIssues.mockResolvedValueOnce({
issues: [],
total: 0,
} as ListIssuesResponse);
const user = userEvent.setup();
render(<IssuesPage />);
await waitFor(() => {
expect(screen.getByText("New Issue")).toBeInTheDocument();
});
await user.click(screen.getByText("New Issue"));
// Create form should be visible
expect(
screen.getByPlaceholderText("Issue title..."),
).toBeInTheDocument();
expect(screen.getByText("Create")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
});
it("creates an issue via the form", async () => {
mockListIssues.mockResolvedValueOnce({
issues: [],
total: 0,
} as ListIssuesResponse);
const newIssue: Issue = {
...issueDefaults,
id: "issue-new",
workspace_id: "ws-1",
title: "New test issue",
description: null,
status: "backlog",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
mockCreateIssue.mockResolvedValueOnce(newIssue);
const user = userEvent.setup();
render(<IssuesPage />);
await waitFor(() => {
expect(screen.getByText("New Issue")).toBeInTheDocument();
});
await user.click(screen.getByText("New Issue"));
await user.type(
screen.getByPlaceholderText("Issue title..."),
"New test issue",
);
await user.click(screen.getByText("Create"));
await waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledWith({
title: "New test issue",
});
});
// New issue should appear
await waitFor(() => {
expect(screen.getByText("New test issue")).toBeInTheDocument();
});
});
it("closes create form on Cancel", async () => {
mockListIssues.mockResolvedValueOnce({
issues: [],
total: 0,
} as ListIssuesResponse);
const user = userEvent.setup();
render(<IssuesPage />);
await waitFor(() => {
expect(screen.getByText("New Issue")).toBeInTheDocument();
});
await user.click(screen.getByText("New Issue"));
expect(
screen.getByPlaceholderText("Issue title..."),
).toBeInTheDocument();
await user.click(screen.getByText("Cancel"));
expect(
screen.queryByPlaceholderText("Issue title..."),
).not.toBeInTheDocument();
expect(screen.getByText("New Issue")).toBeInTheDocument();
});
it("handles API error gracefully", async () => {
mockListIssues.mockRejectedValueOnce(new Error("Network error"));
render(<IssuesPage />);
// Should finish loading without crashing
await waitFor(() => {
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});
});
});

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import Link from "next/link";
import {
Columns3,
@ -15,7 +15,6 @@ import {
CircleAlert,
Eye,
Minus,
MessageSquare,
} from "lucide-react";
import {
DndContext,
@ -30,14 +29,12 @@ import {
} from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { IssueStatus, IssuePriority } from "@multica/types";
import {
MOCK_ISSUES,
STATUS_CONFIG,
PRIORITY_CONFIG,
type MockIssue,
type MockAssignee,
} from "./_data/mock";
import type { Issue, IssueStatus, IssuePriority } from "@multica/types";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "./_data/mock";
import { api } from "../../../lib/api";
import { useAuth } from "../../../lib/auth-context";
import { useWSEvent } from "../../../lib/ws-context";
import type { IssueCreatedPayload, IssueUpdatedPayload, IssueDeletedPayload } from "@multica/types";
// ---------------------------------------------------------------------------
// Shared icon components
@ -98,27 +95,30 @@ export function PriorityIcon({
}
function AssigneeAvatar({
assignee,
issue,
size = "sm",
}: {
assignee: MockAssignee | null;
issue: Issue;
size?: "sm" | "md";
}) {
if (!assignee) return null;
const { getActorName, getActorInitials } = useAuth();
if (!issue.assignee_type || !issue.assignee_id) return null;
const name = getActorName(issue.assignee_type, issue.assignee_id);
const initials = getActorInitials(issue.assignee_type, issue.assignee_id);
const sizeClass = size === "sm" ? "h-5 w-5 text-[10px]" : "h-6 w-6 text-xs";
return (
<div
className={`flex shrink-0 items-center justify-center rounded-full font-medium ${sizeClass} ${
assignee.type === "agent"
issue.assignee_type === "agent"
? "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
: "bg-muted text-muted-foreground"
}`}
title={assignee.name}
title={name}
>
{assignee.type === "agent" ? (
{issue.assignee_type === "agent" ? (
<Bot className={size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"} />
) : (
assignee.avatar.charAt(0)
initials
)}
</div>
);
@ -132,30 +132,24 @@ function formatDate(date: string): string {
}
// ---------------------------------------------------------------------------
// Board View — Card (static, used in both draggable wrapper and overlay)
// Board View — Card
// ---------------------------------------------------------------------------
function BoardCardContent({ issue }: { issue: MockIssue }) {
function BoardCardContent({ issue }: { issue: Issue }) {
return (
<div className="rounded-lg border bg-background p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<PriorityIcon priority={issue.priority} />
<span>{issue.key}</span>
<span>{issue.id.slice(0, 8)}</span>
</div>
<p className="mt-1.5 text-[13px] leading-snug">{issue.title}</p>
<div className="mt-2.5 flex items-center justify-between">
<div className="flex items-center gap-2">
<AssigneeAvatar assignee={issue.assignee} />
{issue.comments.length > 0 && (
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
<MessageSquare className="h-3 w-3" />
{issue.comments.length}
</span>
)}
<AssigneeAvatar issue={issue} />
</div>
{issue.dueDate && (
{issue.due_date && (
<span className="text-xs text-muted-foreground">
{formatDate(issue.dueDate)}
{formatDate(issue.due_date)}
</span>
)}
</div>
@ -167,7 +161,7 @@ function BoardCardContent({ issue }: { issue: MockIssue }) {
// Draggable card wrapper
// ---------------------------------------------------------------------------
function DraggableBoardCard({ issue }: { issue: MockIssue }) {
function DraggableBoardCard({ issue }: { issue: Issue }) {
const {
attributes,
listeners,
@ -196,7 +190,6 @@ function DraggableBoardCard({ issue }: { issue: MockIssue }) {
<Link
href={`/issues/${issue.id}`}
onClick={(e) => {
// Prevent navigation when dragging
if (isDragging) e.preventDefault();
}}
className="block transition-colors hover:opacity-80"
@ -216,7 +209,7 @@ function DroppableColumn({
issues,
}: {
status: IssueStatus;
issues: MockIssue[];
issues: Issue[];
}) {
const cfg = STATUS_CONFIG[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
@ -250,10 +243,10 @@ function BoardView({
issues,
onMoveIssue,
}: {
issues: MockIssue[];
issues: Issue[];
onMoveIssue: (issueId: string, newStatus: IssueStatus) => void;
}) {
const [activeIssue, setActiveIssue] = useState<MockIssue | null>(null);
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
@ -284,14 +277,11 @@ function BoardView({
if (!over) return;
const issueId = active.id as string;
// `over.id` is the column's droppable id (a status string)
// or another card's sortable id
let targetStatus: IssueStatus | undefined;
if (visibleStatuses.includes(over.id as IssueStatus)) {
targetStatus = over.id as IssueStatus;
} else {
// Dropped on a card — find which column that card is in
const targetIssue = issues.find((i) => i.id === over.id);
if (targetIssue) targetStatus = targetIssue.status;
}
@ -338,27 +328,29 @@ function BoardView({
// List View
// ---------------------------------------------------------------------------
function ListRow({ issue }: { issue: MockIssue }) {
function ListRow({ issue }: { issue: Issue }) {
return (
<Link
href={`/issues/${issue.id}`}
className="flex h-9 items-center gap-2 px-4 text-[13px] transition-colors hover:bg-accent/50"
>
<PriorityIcon priority={issue.priority} />
<span className="w-16 shrink-0 text-xs text-muted-foreground">{issue.key}</span>
<span className="w-16 shrink-0 text-xs text-muted-foreground">
{issue.id.slice(0, 8)}
</span>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
{issue.dueDate && (
{issue.due_date && (
<span className="shrink-0 text-xs text-muted-foreground">
{formatDate(issue.dueDate)}
{formatDate(issue.due_date)}
</span>
)}
<AssigneeAvatar assignee={issue.assignee} />
<AssigneeAvatar issue={issue} />
</Link>
);
}
function ListView({ issues }: { issues: MockIssue[] }) {
function ListView({ issues }: { issues: Issue[] }) {
const groupOrder: IssueStatus[] = [
"in_review",
"in_progress",
@ -390,6 +382,69 @@ function ListView({ issues }: { issues: MockIssue[] }) {
);
}
// ---------------------------------------------------------------------------
// Create Issue Dialog (simple inline)
// ---------------------------------------------------------------------------
function CreateIssueForm({ onCreated }: { onCreated: (issue: Issue) => void }) {
const [title, setTitle] = useState("");
const [isOpen, setIsOpen] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
try {
const issue = await api.createIssue({ title: title.trim() });
onCreated(issue);
setTitle("");
setIsOpen(false);
} catch (err) {
console.error("Failed to create issue:", err);
}
};
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus className="h-3.5 w-3.5" />
New Issue
</button>
);
}
return (
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<input
autoFocus
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") setIsOpen(false);
}}
placeholder="Issue title..."
className="rounded-md border bg-background px-2 py-1 text-xs w-48"
/>
<button
type="submit"
className="rounded-md bg-primary px-2 py-1 text-xs text-primary-foreground"
>
Create
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
className="text-xs text-muted-foreground"
>
Cancel
</button>
</form>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
@ -398,21 +453,78 @@ type ViewMode = "board" | "list";
export default function IssuesPage() {
const [view, setView] = useState<ViewMode>("board");
const [issues, setIssues] = useState<MockIssue[]>(MOCK_ISSUES);
const [issues, setIssues] = useState<Issue[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.listIssues({ limit: 200 })
.then((res) => {
setIssues(res.issues);
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
// Real-time updates
useWSEvent(
"issue:created",
useCallback((payload: unknown) => {
const { issue } = payload as IssueCreatedPayload;
setIssues((prev) => {
if (prev.some((i) => i.id === issue.id)) return prev;
return [...prev, issue];
});
}, []),
);
useWSEvent(
"issue:updated",
useCallback((payload: unknown) => {
const { issue } = payload as IssueUpdatedPayload;
setIssues((prev) => prev.map((i) => (i.id === issue.id ? issue : i)));
}, []),
);
useWSEvent(
"issue:deleted",
useCallback((payload: unknown) => {
const { issue_id } = payload as IssueDeletedPayload;
setIssues((prev) => prev.filter((i) => i.id !== issue_id));
}, []),
);
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus) => {
// Optimistic update
setIssues((prev) =>
prev.map((issue) =>
issue.id === issueId
? { ...issue, status: newStatus, updatedAt: new Date().toISOString() }
: issue
issue.id === issueId ? { ...issue, status: newStatus } : issue
)
);
// Persist to API
api.updateIssue(issueId, { status: newStatus }).catch((err) => {
console.error("Failed to update issue:", err);
// Revert on error
api.listIssues({ limit: 200 }).then((res) => setIssues(res.issues));
});
},
[]
);
const handleIssueCreated = useCallback((issue: Issue) => {
setIssues((prev) => [...prev, issue]);
}, []);
if (loading) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading...
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
@ -444,10 +556,7 @@ export default function IssuesPage() {
</button>
</div>
</div>
<button className="flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/90">
<Plus className="h-3.5 w-3.5" />
New Issue
</button>
<CreateIssueForm onCreated={handleIssueCreated} />
</div>
<div className="flex-1 overflow-hidden">

View file

@ -31,15 +31,15 @@ function renderMarkdown(text: string): React.ReactNode[] {
let i = 0;
while (i < lines.length) {
const line = lines[i];
const line = lines[i]!;
// Code block
if (line.startsWith("```")) {
const lang = line.slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
while (i < lines.length && !lines[i]!.startsWith("```")) {
codeLines.push(lines[i]!);
i++;
}
i++; // skip closing ```
@ -57,8 +57,8 @@ function renderMarkdown(text: string): React.ReactNode[] {
// Table (simplified: detect | pipes)
if (line.includes("|") && line.trim().startsWith("|")) {
const tableRows: string[] = [];
while (i < lines.length && lines[i].includes("|") && lines[i].trim().startsWith("|")) {
tableRows.push(lines[i]);
while (i < lines.length && lines[i]!.includes("|") && lines[i]!.trim().startsWith("|")) {
tableRows.push(lines[i]!);
i++;
}
// Filter out separator rows (|---|---|)
@ -66,7 +66,7 @@ function renderMarkdown(text: string): React.ReactNode[] {
if (dataRows.length > 0) {
const parseRow = (row: string) =>
row.split("|").filter((c) => c.trim() !== "").map((c) => c.trim());
const header = parseRow(dataRows[0]);
const header = parseRow(dataRows[0]!);
const body = dataRows.slice(1).map(parseRow);
elements.push(
<div key={`table-${i}`} className="my-3 overflow-x-auto">
@ -103,7 +103,7 @@ function renderMarkdown(text: string): React.ReactNode[] {
elements.push(
<h2 key={`h2-${i}`} className="mt-6 mb-2 text-[15px] font-semibold">
{line.slice(3)}
</h2>
</h2>,
);
i++;
continue;
@ -112,14 +112,14 @@ function renderMarkdown(text: string): React.ReactNode[] {
elements.push(
<h3 key={`h3-${i}`} className="mt-4 mb-1.5 text-[14px] font-medium">
{line.slice(4)}
</h3>
</h3>,
);
i++;
continue;
}
// List item
if (line.match(/^- \[[ x]\] /)) {
if (/^- \[[ x]\] /.test(line)) {
const checked = line.includes("[x]");
const text = line.replace(/^- \[[ x]\] /, "");
elements.push(
@ -142,8 +142,8 @@ function renderMarkdown(text: string): React.ReactNode[] {
continue;
}
// Numbered list
if (line.match(/^\d+\. /)) {
const num = line.match(/^(\d+)\. /)![1];
if (/^\d+\. /.test(line)) {
const num = line.match(/^(\d+)\. /)![1]!;
const text = line.replace(/^\d+\. /, "");
elements.push(
<div key={`ol-${i}`} className="flex gap-2 py-0.5 text-[13px] text-foreground/80">
@ -155,7 +155,7 @@ function renderMarkdown(text: string): React.ReactNode[] {
continue;
}
// Empty line
// Empty line — guard is redundant since line is already asserted, but keeps TS happy
if (line.trim() === "") {
elements.push(<div key={`br-${i}`} className="h-2" />);
i++;

View file

@ -1,15 +1,22 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useCallback } from "react";
import {
Inbox,
ListTodo,
Bot,
BookOpen,
ChevronDown,
Settings,
LogOut,
Plus,
} from "lucide-react";
import { MulticaIcon } from "@multica/ui/components/multica-icon";
import { useAuth } from "../../lib/auth-context";
import type { Workspace } from "@multica/types";
import { api } from "../../lib/api";
const navItems = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
@ -24,18 +31,75 @@ export default function DashboardLayout({
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const { user, workspace, isLoading, logout } = useAuth();
const [showMenu, setShowMenu] = useState(false);
useEffect(() => {
if (!isLoading && !user) {
router.push("/login");
}
}, [user, isLoading, router]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6" />
</div>
);
}
if (!user) return null;
return (
<div className="flex h-screen bg-canvas">
{/* Sidebar — sits on the canvas layer */}
{/* Sidebar */}
<aside className="flex w-56 shrink-0 flex-col">
{/* Workspace Switcher */}
<div className="flex h-12 items-center gap-2 px-3">
<MulticaIcon className="size-4" noSpin />
<span className="flex-1 truncate text-sm font-semibold">
Multica
</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="flex h-12 w-full items-center gap-2 px-3 hover:bg-sidebar-accent/50 transition-colors"
>
<MulticaIcon className="size-4" noSpin />
<span className="flex-1 truncate text-left text-sm font-semibold">
{workspace?.name ?? "Multica"}
</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
{showMenu && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowMenu(false)}
/>
<div className="absolute left-2 top-12 z-50 w-52 rounded-lg border bg-popover p-1 shadow-md">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{user.email}
</div>
<div className="my-1 border-t" />
<Link
href="/settings"
onClick={() => setShowMenu(false)}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
>
<Settings className="h-3.5 w-3.5" />
Settings
</Link>
<button
onClick={() => {
setShowMenu(false);
logout();
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-red-500 hover:bg-accent"
>
<LogOut className="h-3.5 w-3.5" />
Sign out
</button>
</div>
</>
)}
</div>
{/* Navigation */}
@ -59,9 +123,26 @@ export default function DashboardLayout({
);
})}
</nav>
{/* User info at bottom */}
<div className="border-t px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium">
{user.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<span className="truncate text-xs text-muted-foreground">
{user.name}
</span>
</div>
</div>
</aside>
{/* Main content — floating panel on top of the canvas */}
{/* Main content */}
<div className="flex-1 pt-1.5 pr-1.5 pb-1.5">
<main className="h-full overflow-auto rounded-xl bg-background shadow-sm">
{children}

View file

@ -1,10 +1,155 @@
export default function SettingsPage() {
"use client";
import { useState } from "react";
import { Settings, Users, Building2, Save, Crown, Shield, User } from "lucide-react";
import type { MemberWithUser, MemberRole } from "@multica/types";
import { useAuth } from "../../../lib/auth-context";
import { api } from "../../../lib/api";
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown }> = {
owner: { label: "Owner", icon: Crown },
admin: { label: "Admin", icon: Shield },
member: { label: "Member", icon: User },
};
function MemberRow({ member }: { member: MemberWithUser }) {
const rc = roleConfig[member.role];
const RoleIcon = rc.icon;
return (
<div className="p-6">
<h1 className="text-2xl font-bold">Settings</h1>
<p className="mt-2 text-muted-foreground">
Workspace settings coming soon.
</p>
<div className="flex items-center gap-3 rounded-lg border px-4 py-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">
{member.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{member.name}</div>
<div className="text-xs text-muted-foreground">{member.email}</div>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<RoleIcon className="h-3 w-3" />
{rc.label}
</div>
</div>
);
}
export default function SettingsPage() {
const { workspace, members } = useAuth();
const [name, setName] = useState(workspace?.name ?? "");
const [description, setDescription] = useState(
workspace?.description ?? "",
);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const handleSave = async () => {
if (!workspace) return;
setSaving(true);
try {
await api.updateWorkspace(workspace.id, {
name,
description: description || undefined,
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {
console.error("Failed to update workspace", e);
} finally {
setSaving(false);
}
};
if (!workspace) return null;
return (
<div className="mx-auto max-w-2xl p-6 space-y-8">
{/* Page header */}
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Settings</h1>
</div>
{/* Workspace info */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Workspace</h2>
</div>
<div className="space-y-3 rounded-lg border p-4">
<div>
<label className="text-xs font-medium text-muted-foreground">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
placeholder="What does this workspace focus on?"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">
Slug
</label>
<div className="mt-1 rounded-md border bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
{workspace.slug}
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
{saved && (
<span className="text-xs text-green-600">Saved!</span>
)}
<button
onClick={handleSave}
disabled={saving || !name.trim()}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
</section>
{/* Members */}
<section className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">
Members ({members.length})
</h2>
</div>
</div>
<div className="space-y-2">
{members.map((m) => (
<MemberRow key={m.id} member={m} />
))}
{members.length === 0 && (
<p className="text-sm text-muted-foreground">No members found.</p>
)}
</div>
</section>
</div>
);
}

View file

@ -1,5 +1,7 @@
import type { Metadata } from "next";
import { ThemeProvider } from "@multica/ui/components/theme-provider";
import { AuthProvider } from "../lib/auth-context";
import { WSProvider } from "../lib/ws-context";
import "./globals.css";
export const metadata: Metadata = {
@ -21,7 +23,9 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
{children}
<AuthProvider>
<WSProvider>{children}</WSProvider>
</AuthProvider>
</ThemeProvider>
</body>
</html>

View file

@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/inbox");
redirect("/issues");
}

17
apps/web/lib/api.ts Normal file
View file

@ -0,0 +1,17 @@
import { ApiClient } from "@multica/sdk";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
export const api = new ApiClient(API_BASE_URL);
// Initialize token from localStorage on load
if (typeof window !== "undefined") {
const token = localStorage.getItem("multica_token");
if (token) {
api.setToken(token);
}
const wsId = localStorage.getItem("multica_workspace_id");
if (wsId) {
api.setWorkspaceId(wsId);
}
}

View file

@ -0,0 +1,281 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
// Mock next/navigation
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
}));
// Must use vi.hoisted so the mock object is defined before vi.mock factory runs
const mockApi = vi.hoisted(() => ({
setToken: vi.fn(),
setWorkspaceId: vi.fn(),
login: vi.fn(),
getMe: vi.fn(),
listWorkspaces: vi.fn(),
listMembers: vi.fn(),
listAgents: vi.fn(),
}));
vi.mock("./api", () => ({
api: mockApi,
}));
import { AuthProvider, useAuth } from "./auth-context";
const mockUser: User = {
id: "user-1",
name: "Test User",
email: "test@multica.ai",
avatar_url: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const mockWorkspace: Workspace = {
id: "ws-1",
name: "Test WS",
slug: "test",
description: null,
settings: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const mockMembers: MemberWithUser[] = [
{
id: "member-1",
workspace_id: "ws-1",
user_id: "user-1",
role: "owner",
created_at: "2026-01-01T00:00:00Z",
name: "Test User",
email: "test@multica.ai",
avatar_url: null,
},
{
id: "member-2",
workspace_id: "ws-1",
user_id: "user-2",
role: "member",
created_at: "2026-01-01T00:00:00Z",
name: "Other User",
email: "other@multica.ai",
avatar_url: null,
},
];
const mockAgents: Agent[] = [
{
id: "agent-1",
workspace_id: "ws-1",
name: "Claude",
avatar_url: null,
status: "idle",
runtime_mode: "cloud",
runtime_config: {},
visibility: "workspace",
max_concurrent_tasks: 3,
owner_id: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
];
function wrapper({ children }: { children: React.ReactNode }) {
return <AuthProvider>{children}</AuthProvider>;
}
describe("AuthContext", () => {
beforeEach(() => {
vi.clearAllMocks();
// Clear localStorage manually since jsdom may not have .clear()
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
});
it("starts with null user when no token stored", async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.user).toBeNull();
expect(result.current.workspace).toBeNull();
});
it("login stores token and navigates to /issues", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai", "Test User");
});
expect(mockApi.login).toHaveBeenCalledWith("test@multica.ai", "Test User");
expect(mockApi.setToken).toHaveBeenCalledWith("test-jwt");
expect(localStorage.getItem("multica_token")).toBe("test-jwt");
expect(result.current.user).toEqual(mockUser);
expect(result.current.workspace).toEqual(mockWorkspace);
expect(result.current.members).toEqual(mockMembers);
expect(result.current.agents).toEqual(mockAgents);
expect(mockPush).toHaveBeenCalledWith("/issues");
});
it("logout clears state and navigates to /login", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
act(() => {
result.current.logout();
});
expect(localStorage.getItem("multica_token")).toBeNull();
expect(localStorage.getItem("multica_workspace_id")).toBeNull();
expect(result.current.user).toBeNull();
expect(result.current.workspace).toBeNull();
expect(result.current.members).toEqual([]);
expect(result.current.agents).toEqual([]);
expect(mockPush).toHaveBeenCalledWith("/login");
});
it("getMemberName returns correct name for known user", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getMemberName("user-1")).toBe("Test User");
expect(result.current.getMemberName("user-2")).toBe("Other User");
expect(result.current.getMemberName("unknown")).toBe("Unknown");
});
it("getAgentName returns correct name for known agent", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getAgentName("agent-1")).toBe("Claude");
expect(result.current.getAgentName("unknown")).toBe("Unknown Agent");
});
it("getActorName dispatches to member or agent", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getActorName("member", "user-1")).toBe("Test User");
expect(result.current.getActorName("agent", "agent-1")).toBe("Claude");
expect(result.current.getActorName("system", "xxx")).toBe("System");
});
it("getActorInitials returns uppercase initials", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getActorInitials("member", "user-1")).toBe("TU");
expect(result.current.getActorInitials("agent", "agent-1")).toBe("C");
});
it("initializes from localStorage token on mount", async () => {
localStorage.setItem("multica_token", "stored-token");
localStorage.setItem("multica_workspace_id", "ws-1");
mockApi.getMe.mockResolvedValueOnce(mockUser);
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(mockApi.setToken).toHaveBeenCalledWith("stored-token");
expect(result.current.user).toEqual(mockUser);
expect(result.current.workspace).toEqual(mockWorkspace);
});
it("clears token when stored token is invalid", async () => {
localStorage.setItem("multica_token", "invalid-token");
mockApi.getMe.mockRejectedValueOnce(new Error("Unauthorized"));
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.user).toBeNull();
expect(localStorage.getItem("multica_token")).toBeNull();
});
});

View file

@ -0,0 +1,194 @@
"use client";
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from "react";
import { useRouter } from "next/navigation";
import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
import { api } from "./api";
interface AuthContextValue {
user: User | null;
workspace: Workspace | null;
members: MemberWithUser[];
agents: Agent[];
isLoading: boolean;
login: (email: string, name?: string) => Promise<void>;
logout: () => void;
refreshMembers: () => Promise<void>;
refreshAgents: () => Promise<void>;
getMemberName: (userId: string) => string;
getAgentName: (agentId: string) => string;
getActorName: (type: string, id: string) => string;
getActorInitials: (type: string, id: string) => string;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [members, setMembers] = useState<MemberWithUser[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
// Initialize from stored token
useEffect(() => {
const token = localStorage.getItem("multica_token");
const wsId = localStorage.getItem("multica_workspace_id");
if (!token) {
setIsLoading(false);
return;
}
api.setToken(token);
if (wsId) api.setWorkspaceId(wsId);
(async () => {
try {
const me = await api.getMe();
setUser(me);
const workspaces = await api.listWorkspaces();
if (workspaces.length > 0) {
const ws = workspaces[0]!;
setWorkspace(ws);
api.setWorkspaceId(ws.id);
localStorage.setItem("multica_workspace_id", ws.id);
const [m, a] = await Promise.all([
api.listMembers(ws.id),
api.listAgents({ workspace_id: ws.id }),
]);
setMembers(m);
setAgents(a);
}
} catch {
// Token invalid, clear it
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
} finally {
setIsLoading(false);
}
})();
}, []);
const login = useCallback(async (email: string, name?: string) => {
const { token, user: u } = await api.login(email, name);
api.setToken(token);
localStorage.setItem("multica_token", token);
setUser(u);
// Load workspace
const workspaces = await api.listWorkspaces();
if (workspaces.length > 0) {
const ws = workspaces[0]!;
setWorkspace(ws);
api.setWorkspaceId(ws.id);
localStorage.setItem("multica_workspace_id", ws.id);
const [m, a] = await Promise.all([
api.listMembers(ws.id),
api.listAgents({ workspace_id: ws.id }),
]);
setMembers(m);
setAgents(a);
}
router.push("/issues");
}, [router]);
const logout = useCallback(() => {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
setUser(null);
setWorkspace(null);
setMembers([]);
setAgents([]);
router.push("/login");
}, [router]);
const refreshMembers = useCallback(async () => {
if (!workspace) return;
const m = await api.listMembers(workspace.id);
setMembers(m);
}, [workspace]);
const refreshAgents = useCallback(async () => {
if (!workspace) return;
const a = await api.listAgents({ workspace_id: workspace.id });
setAgents(a);
}, [workspace]);
const getMemberName = useCallback(
(userId: string) => {
const m = members.find((m) => m.user_id === userId);
return m?.name ?? "Unknown";
},
[members],
);
const getAgentName = useCallback(
(agentId: string) => {
const a = agents.find((a) => a.id === agentId);
return a?.name ?? "Unknown Agent";
},
[agents],
);
const getActorName = useCallback(
(type: string, id: string) => {
if (type === "member") return getMemberName(id);
if (type === "agent") return getAgentName(id);
return "System";
},
[getMemberName, getAgentName],
);
const getActorInitials = useCallback(
(type: string, id: string) => {
const name = getActorName(type, id);
return name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
},
[getActorName],
);
return (
<AuthContext.Provider
value={{
user,
workspace,
members,
agents,
isLoading,
login,
logout,
refreshMembers,
refreshAgents,
getMemberName,
getAgentName,
getActorName,
getActorInitials,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View file

@ -0,0 +1,75 @@
"use client";
import {
createContext,
useContext,
useEffect,
useRef,
useCallback,
type ReactNode,
} from "react";
import { WSClient } from "@multica/sdk";
import type { WSEventType } from "@multica/types";
import { useAuth } from "./auth-context";
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080/ws";
type EventHandler = (payload: unknown) => void;
interface WSContextValue {
subscribe: (event: WSEventType, handler: EventHandler) => () => void;
}
const WSContext = createContext<WSContextValue | null>(null);
export function WSProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const wsRef = useRef<WSClient | null>(null);
useEffect(() => {
if (!user) return;
const ws = new WSClient(WS_URL);
wsRef.current = ws;
ws.connect();
return () => {
ws.disconnect();
wsRef.current = null;
};
}, [user]);
const subscribe = useCallback(
(event: WSEventType, handler: EventHandler) => {
const ws = wsRef.current;
if (!ws) return () => {};
return ws.on(event, handler);
},
[],
);
return (
<WSContext.Provider value={{ subscribe }}>
{children}
</WSContext.Provider>
);
}
export function useWS() {
const ctx = useContext(WSContext);
if (!ctx) throw new Error("useWS must be used within WSProvider");
return ctx;
}
/**
* Hook that subscribes to a WebSocket event and calls the handler.
* Automatically unsubscribes on cleanup.
*/
export function useWSEvent(event: WSEventType, handler: EventHandler) {
const { subscribe } = useWS();
useEffect(() => {
const unsub = subscribe(event, handler);
return unsub;
}, [event, handler, subscribe]);
}

View file

@ -8,7 +8,8 @@
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"lint": "next lint"
"lint": "next lint",
"test": "vitest run"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -28,9 +29,15 @@
},
"devDependencies": {
"@tailwindcss/postcss": "catalog:",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^29.0.1",
"tailwindcss": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "^4.1.0"
}
}

93
apps/web/test/helpers.tsx Normal file
View file

@ -0,0 +1,93 @@
import React from "react";
import { vi } from "vitest";
import { render, type RenderOptions } from "@testing-library/react";
import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
// Mock user
export const mockUser: User = {
id: "user-1",
name: "Test User",
email: "test@multica.ai",
avatar_url: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
// Mock workspace
export const mockWorkspace: Workspace = {
id: "ws-1",
name: "Test Workspace",
slug: "test-ws",
description: "A test workspace",
settings: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
// Mock members
export const mockMembers: MemberWithUser[] = [
{
id: "member-1",
workspace_id: "ws-1",
user_id: "user-1",
role: "owner",
created_at: "2026-01-01T00:00:00Z",
name: "Test User",
email: "test@multica.ai",
avatar_url: null,
},
];
// Mock agents
export const mockAgents: Agent[] = [
{
id: "agent-1",
workspace_id: "ws-1",
name: "Claude Agent",
avatar_url: null,
status: "idle",
runtime_mode: "cloud",
runtime_config: {},
visibility: "workspace",
max_concurrent_tasks: 3,
owner_id: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
];
// Mock auth context value
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const mockAuthValue: Record<string, any> = {
user: mockUser,
workspace: mockWorkspace,
members: mockMembers,
agents: mockAgents,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
refreshMembers: vi.fn(),
refreshAgents: vi.fn(),
getMemberName: (userId: string) => {
const m = mockMembers.find((m) => m.user_id === userId);
return m?.name ?? "Unknown";
},
getAgentName: (agentId: string) => {
const a = mockAgents.find((a) => a.id === agentId);
return a?.name ?? "Unknown Agent";
},
getActorName: (type: string, id: string) => {
if (type === "member") {
const m = mockMembers.find((m) => m.user_id === id);
return m?.name ?? "Unknown";
}
if (type === "agent") {
const a = mockAgents.find((a) => a.id === id);
return a?.name ?? "Unknown Agent";
}
return "System";
},
getActorInitials: (type: string, id: string) => {
return "TU";
},
};

33
apps/web/test/setup.ts Normal file
View file

@ -0,0 +1,33 @@
import "@testing-library/jest-dom/vitest";
import { vi } from "vitest";
// jsdom 29 / Node.js 22+ may not provide a proper Web Storage API.
// Create a proper localStorage mock if methods are missing.
if (
typeof globalThis.localStorage === "undefined" ||
typeof globalThis.localStorage.getItem !== "function"
) {
const store: Record<string, string> = {};
const localStorageMock = {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
for (const key of Object.keys(store)) {
delete store[key];
}
}),
get length() {
return Object.keys(store).length;
},
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
};
Object.defineProperty(globalThis, "localStorage", {
value: localStorageMock,
writable: true,
});
}

19
apps/web/vitest.config.ts Normal file
View file

@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./test/setup.ts"],
include: ["**/*.test.{ts,tsx}"],
},
resolve: {
alias: {
"@multica/types": path.resolve(__dirname, "../../packages/types/src"),
"@multica/sdk": path.resolve(__dirname, "../../packages/sdk/src"),
},
},
});

46
e2e/auth.spec.ts Normal file
View file

@ -0,0 +1,46 @@
import { test, expect } from "@playwright/test";
import { loginAsDefault, openWorkspaceMenu } from "./helpers";
test.describe("Authentication", () => {
test("login page renders correctly", async ({ page }) => {
await page.goto("/login");
await expect(page.locator("h1")).toContainText("Multica");
await expect(page.locator('input[placeholder="Email"]')).toBeVisible();
await expect(page.locator('input[placeholder="Name"]')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toContainText(
"Sign in",
);
});
test("login and redirect to /issues", async ({ page }) => {
await loginAsDefault(page);
await expect(page).toHaveURL(/\/issues/);
await expect(page.locator("text=All Issues")).toBeVisible();
});
test("unauthenticated user is redirected to /login", async ({ page }) => {
await page.goto("/login");
await page.evaluate(() => {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
});
await page.goto("/issues");
await page.waitForURL("**/login", { timeout: 10000 });
});
test("logout redirects to /login", async ({ page }) => {
await loginAsDefault(page);
// Open the workspace dropdown menu
await openWorkspaceMenu(page);
// Click Sign out
await page.locator("text=Sign out").click();
await page.waitForURL("**/login", { timeout: 10000 });
await expect(page).toHaveURL(/\/login/);
});
});

47
e2e/comments.spec.ts Normal file
View file

@ -0,0 +1,47 @@
import { test, expect } from "@playwright/test";
import { loginAsDefault } from "./helpers";
test.describe("Comments", () => {
test("can add a comment on an issue", async ({ page }) => {
await loginAsDefault(page);
// Wait for issues to load and click first one
const issueLink = page.locator('a[href^="/issues/"]').first();
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();
await page.waitForURL(/\/issues\/[\w-]+/);
// Wait for issue detail to load
await expect(page.locator("text=Properties")).toBeVisible();
// Type a comment
const commentText = "E2E comment " + Date.now();
const commentInput = page.locator(
'input[placeholder="Leave a comment..."]',
);
await commentInput.fill(commentText);
// Submit the comment
await page.locator('form button[type="submit"]').last().click();
// Comment should appear in the activity section
await expect(page.locator(`text=${commentText}`)).toBeVisible({
timeout: 5000,
});
});
test("comment submit button is disabled when empty", async ({ page }) => {
await loginAsDefault(page);
const issueLink = page.locator('a[href^="/issues/"]').first();
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();
await page.waitForURL(/\/issues\/[\w-]+/);
await expect(page.locator("text=Properties")).toBeVisible();
// Submit button should be disabled when input is empty
const submitBtn = page.locator('form button[type="submit"]').last();
await expect(submitBtn).toBeDisabled();
});
});

22
e2e/helpers.ts Normal file
View file

@ -0,0 +1,22 @@
import { type Page } from "@playwright/test";
/**
* Login as the seeded user (has workspace and issues).
*/
export async function loginAsDefault(page: Page) {
await page.goto("/login");
await page.fill('input[placeholder="Name"]', "Jiayuan Zhang");
await page.fill('input[placeholder="Email"]', "jiayuan@multica.ai");
await page.click('button[type="submit"]');
await page.waitForURL("**/issues", { timeout: 10000 });
}
/**
* Open the workspace switcher dropdown menu.
*/
export async function openWorkspaceMenu(page: Page) {
// Click the workspace switcher button (has ChevronDown icon)
await page.locator("aside button").first().click();
// Wait for dropdown to appear
await page.locator('[class*="popover"]').waitFor({ state: "visible" });
}

77
e2e/issues.spec.ts Normal file
View file

@ -0,0 +1,77 @@
import { test, expect } from "@playwright/test";
import { loginAsDefault } from "./helpers";
test.describe("Issues", () => {
test.beforeEach(async ({ page }) => {
await loginAsDefault(page);
});
test("issues page loads with board view", async ({ page }) => {
await expect(page.locator("text=All Issues")).toBeVisible();
// Board columns should be visible
await expect(page.locator("text=Backlog")).toBeVisible();
await expect(page.locator("text=Todo")).toBeVisible();
await expect(page.locator("text=In Progress")).toBeVisible();
});
test("can switch between board and list view", async ({ page }) => {
await expect(page.locator("text=All Issues")).toBeVisible();
// Switch to list view
await page.click("text=List");
await expect(page.locator("text=All Issues")).toBeVisible();
// Switch back to board view
await page.click("text=Board");
await expect(page.locator("text=Backlog")).toBeVisible();
});
test("can create a new issue", async ({ page }) => {
await page.click("text=New Issue");
const title = "E2E Created " + Date.now();
await page.fill('input[placeholder="Issue title..."]', title);
await page.click("text=Create");
// New issue should appear on the page
await expect(page.locator(`text=${title}`).first()).toBeVisible({
timeout: 10000,
});
});
test("can navigate to issue detail page", async ({ page }) => {
// Wait for issues to load
await expect(page.locator("text=All Issues")).toBeVisible();
// Click first issue card that has an anchor tag to issue detail
const issueLink = page.locator('a[href^="/issues/"]').first();
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();
// Should navigate to issue detail
await page.waitForURL(/\/issues\/[\w-]+/);
// Should show Properties panel
await expect(page.locator("text=Properties")).toBeVisible();
// Should show breadcrumb link back to Issues
await expect(
page.locator("a", { hasText: "Issues" }).first(),
).toBeVisible();
});
test("can cancel issue creation", async ({ page }) => {
await page.click("text=New Issue");
await expect(
page.locator('input[placeholder="Issue title..."]'),
).toBeVisible();
await page.click("text=Cancel");
await expect(
page.locator('input[placeholder="Issue title..."]'),
).not.toBeVisible();
await expect(page.locator("text=New Issue")).toBeVisible();
});
});

43
e2e/navigation.spec.ts Normal file
View file

@ -0,0 +1,43 @@
import { test, expect } from "@playwright/test";
import { loginAsDefault, openWorkspaceMenu } from "./helpers";
test.describe("Navigation", () => {
test.beforeEach(async ({ page }) => {
await loginAsDefault(page);
});
test("sidebar navigation works", async ({ page }) => {
// Click Inbox
await page.locator("nav a", { hasText: "Inbox" }).click();
await page.waitForURL("**/inbox");
await expect(page).toHaveURL(/\/inbox/);
// Click Agents
await page.locator("nav a", { hasText: "Agents" }).click();
await page.waitForURL("**/agents");
await expect(page).toHaveURL(/\/agents/);
// Click Issues
await page.locator("nav a", { hasText: "Issues" }).click();
await page.waitForURL("**/issues");
await expect(page).toHaveURL(/\/issues/);
});
test("settings page loads via workspace menu", async ({ page }) => {
// Settings is inside the workspace dropdown menu
await openWorkspaceMenu(page);
await page.locator("text=Settings").click();
await page.waitForURL("**/settings");
await expect(page.locator("text=Workspace")).toBeVisible();
await expect(page.locator("text=Members")).toBeVisible();
});
test("agents page shows agent list", async ({ page }) => {
await page.locator("nav a", { hasText: "Agents" }).click();
await page.waitForURL("**/agents");
// Should show "Agents" heading
await expect(page.locator("text=Agents").first()).toBeVisible();
});
});

View file

@ -22,6 +22,7 @@
}
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/node": "catalog:",
"turbo": "^2.5.0",
"typescript": "catalog:"

View file

@ -5,11 +5,21 @@ import type {
ListIssuesResponse,
Agent,
InboxItem,
Comment,
Workspace,
MemberWithUser,
User,
} from "@multica/types";
export interface LoginResponse {
token: string;
user: User;
}
export class ApiClient {
private baseUrl: string;
private token: string | null = null;
private workspaceId: string | null = null;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
@ -19,6 +29,10 @@ export class ApiClient {
this.token = token;
}
setWorkspaceId(id: string) {
this.workspaceId = id;
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
@ -27,6 +41,9 @@ export class ApiClient {
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
}
if (this.workspaceId) {
headers["X-Workspace-ID"] = this.workspaceId;
}
const res = await fetch(`${this.baseUrl}${path}`, {
...init,
@ -37,14 +54,33 @@ export class ApiClient {
throw new Error(`API error: ${res.status} ${res.statusText}`);
}
// Handle 204 No Content
if (res.status === 204) {
return undefined as T;
}
return res.json() as Promise<T>;
}
// Auth
async login(email: string, name?: string): Promise<LoginResponse> {
return this.fetch("/auth/login", {
method: "POST",
body: JSON.stringify({ email, name }),
});
}
async getMe(): Promise<User> {
return this.fetch("/api/me");
}
// Issues
async listIssues(params?: { limit?: number; offset?: number }): Promise<ListIssuesResponse> {
async listIssues(params?: { limit?: number; offset?: number; workspace_id?: string }): Promise<ListIssuesResponse> {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", String(params.limit));
if (params?.offset) search.set("offset", String(params.offset));
const wsId = params?.workspace_id ?? this.workspaceId;
if (wsId) search.set("workspace_id", wsId);
return this.fetch(`/api/issues?${search}`);
}
@ -53,7 +89,9 @@ export class ApiClient {
}
async createIssue(data: CreateIssueRequest): Promise<Issue> {
return this.fetch("/api/issues", {
const search = new URLSearchParams();
if (this.workspaceId) search.set("workspace_id", this.workspaceId);
return this.fetch(`/api/issues?${search}`, {
method: "POST",
body: JSON.stringify(data),
});
@ -70,9 +108,24 @@ export class ApiClient {
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
}
// Comments
async listComments(issueId: string): Promise<Comment[]> {
return this.fetch(`/api/issues/${issueId}/comments`);
}
async createComment(issueId: string, content: string, type?: string): Promise<Comment> {
return this.fetch(`/api/issues/${issueId}/comments`, {
method: "POST",
body: JSON.stringify({ content, type: type ?? "comment" }),
});
}
// Agents
async listAgents(): Promise<Agent[]> {
return this.fetch("/api/agents");
async listAgents(params?: { workspace_id?: string }): Promise<Agent[]> {
const search = new URLSearchParams();
const wsId = params?.workspace_id ?? this.workspaceId;
if (wsId) search.set("workspace_id", wsId);
return this.fetch(`/api/agents?${search}`);
}
async getAgent(id: string): Promise<Agent> {
@ -87,4 +140,36 @@ export class ApiClient {
async markInboxRead(id: string): Promise<void> {
await this.fetch(`/api/inbox/${id}/read`, { method: "POST" });
}
async archiveInbox(id: string): Promise<void> {
await this.fetch(`/api/inbox/${id}/archive`, { method: "POST" });
}
// Workspaces
async listWorkspaces(): Promise<Workspace[]> {
return this.fetch("/api/workspaces");
}
async getWorkspace(id: string): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`);
}
async createWorkspace(data: { name: string; slug: string; description?: string }): Promise<Workspace> {
return this.fetch("/api/workspaces", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateWorkspace(id: string, data: { name?: string; description?: string; settings?: Record<string, unknown> }): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
// Members
async listMembers(workspaceId: string): Promise<MemberWithUser[]> {
return this.fetch(`/api/workspaces/${workspaceId}/members`);
}
}

View file

@ -1,2 +1,9 @@
export { ApiClient } from "./api-client.js";
export { WSClient } from "./ws-client.js";
export { ApiClient } from "./api-client";
export type { LoginResponse } from "./api-client";
export { WSClient } from "./ws-client";
export interface ContentBlock {
type: "text" | "image" | "tool_use" | "tool_result";
text?: string;
[key: string]: unknown;
}

View file

@ -1,6 +1,6 @@
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue.js";
export type { Agent, AgentStatus, AgentRuntimeMode, AgentVisibility } from "./agent.js";
export type { Workspace, Member, MemberRole } from "./workspace.js";
export type { Workspace, Member, MemberRole, User, MemberWithUser } from "./workspace.js";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox.js";
export type { Comment, CommentType, CommentAuthorType } from "./comment.js";
export type * from "./events.js";

View file

@ -26,3 +26,14 @@ export interface User {
created_at: string;
updated_at: string;
}
export interface MemberWithUser {
id: string;
workspace_id: string;
user_id: string;
role: MemberRole;
created_at: string;
name: string;
email: string;
avatar_url: string | null;
}

19
playwright.config.ts Normal file
View file

@ -0,0 +1,19 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 30000,
retries: 0,
use: {
baseURL: "http://localhost:3000",
headless: true,
},
projects: [
{
name: "chromium",
use: { browserName: "chromium" },
},
],
// Don't auto-start servers — they must be running already
// This avoids complexity and port conflicts during testing
});

1058
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

131
server/cmd/migrate/main.go Normal file
View file

@ -0,0 +1,131 @@
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: go run ./cmd/migrate <up|down>")
os.Exit(1)
}
direction := os.Args[1]
if direction != "up" && direction != "down" {
fmt.Println("Usage: go run ./cmd/migrate <up|down>")
os.Exit(1)
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
log.Fatalf("Unable to connect to database: %v", err)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
log.Fatalf("Unable to ping database: %v", err)
}
// Create migrations tracking table
_, err = pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`)
if err != nil {
log.Fatalf("Failed to create migrations table: %v", err)
}
// Find migration files
migrationsDir := "migrations"
if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
// Try from server/ directory
migrationsDir = "server/migrations"
}
suffix := "." + direction + ".sql"
files, err := filepath.Glob(filepath.Join(migrationsDir, "*"+suffix))
if err != nil {
log.Fatalf("Failed to find migration files: %v", err)
}
if direction == "up" {
sort.Strings(files)
} else {
sort.Sort(sort.Reverse(sort.StringSlice(files)))
}
for _, file := range files {
version := extractVersion(file)
if direction == "up" {
// Check if already applied
var exists bool
err := pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists)
if err != nil {
log.Fatalf("Failed to check migration status: %v", err)
}
if exists {
fmt.Printf(" skip %s (already applied)\n", version)
continue
}
} else {
// Check if applied (only rollback applied ones)
var exists bool
err := pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists)
if err != nil {
log.Fatalf("Failed to check migration status: %v", err)
}
if !exists {
fmt.Printf(" skip %s (not applied)\n", version)
continue
}
}
sql, err := os.ReadFile(file)
if err != nil {
log.Fatalf("Failed to read %s: %v", file, err)
}
_, err = pool.Exec(ctx, string(sql))
if err != nil {
log.Fatalf("Failed to run %s: %v", file, err)
}
if direction == "up" {
_, err = pool.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version)
} else {
_, err = pool.Exec(ctx, "DELETE FROM schema_migrations WHERE version = $1", version)
}
if err != nil {
log.Fatalf("Failed to record migration %s: %v", version, err)
}
fmt.Printf(" %s %s\n", direction, version)
}
fmt.Println("Done.")
}
func extractVersion(filename string) string {
base := filepath.Base(filename)
// Remove .up.sql or .down.sql
base = strings.TrimSuffix(base, ".up.sql")
base = strings.TrimSuffix(base, ".down.sql")
return base
}

162
server/cmd/seed/main.go Normal file
View file

@ -0,0 +1,162 @@
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
log.Fatalf("Unable to connect to database: %v", err)
}
defer pool.Close()
// Create seed user
var userID string
err = pool.QueryRow(ctx, `
INSERT INTO "user" (name, email, avatar_url)
VALUES ('Jiayuan Zhang', 'jiayuan@multica.ai', NULL)
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name
RETURNING id
`).Scan(&userID)
if err != nil {
log.Fatalf("Failed to create user: %v", err)
}
fmt.Printf("User created: %s\n", userID)
// Create seed workspace
var workspaceID string
err = pool.QueryRow(ctx, `
INSERT INTO workspace (name, slug, description)
VALUES ('Multica', 'multica', 'AI-native task management')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
RETURNING id
`).Scan(&workspaceID)
if err != nil {
log.Fatalf("Failed to create workspace: %v", err)
}
fmt.Printf("Workspace created: %s\n", workspaceID)
// Add user as owner
_, err = pool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'owner')
ON CONFLICT (workspace_id, user_id) DO NOTHING
`, workspaceID, userID)
if err != nil {
log.Fatalf("Failed to create member: %v", err)
}
fmt.Println("Member created")
// Create some agents
agents := []struct {
name string
runtimeMode string
status string
}{
{"Claude-1", "cloud", "idle"},
{"Claude-2", "cloud", "working"},
{"Local Agent", "local", "offline"},
{"Code Review Bot", "cloud", "idle"},
}
for _, a := range agents {
var agentID string
// Check if agent already exists
err = pool.QueryRow(ctx, `
SELECT id FROM agent WHERE workspace_id = $1 AND name = $2
`, workspaceID, a.name).Scan(&agentID)
if err == nil {
fmt.Printf("Agent exists: %s (%s)\n", a.name, agentID)
continue
}
err = pool.QueryRow(ctx, `
INSERT INTO agent (workspace_id, name, runtime_mode, status, owner_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, workspaceID, a.name, a.runtimeMode, a.status, userID).Scan(&agentID)
if err != nil {
log.Printf("Failed to create agent %s: %v", a.name, err)
continue
}
fmt.Printf("Agent created: %s (%s)\n", a.name, agentID)
}
// Create seed issues
issues := []struct {
title string
description string
status string
priority string
position float64
}{
{"Add multi-workspace support", "Users should be able to create and switch between multiple workspaces.", "backlog", "medium", 1},
{"Agent long-term memory persistence", "Agents need persistent memory across sessions for better context.", "backlog", "low", 2},
{"Design the agent config UI", "Create a configuration interface for agent settings and capabilities.", "todo", "high", 3},
{"Implement issue list API endpoint", "Build the REST API for listing, filtering, and paginating issues.", "in_progress", "urgent", 4},
{"Implement OAuth login flow", "Set up OAuth 2.0 with Google for user authentication.", "in_progress", "high", 5},
{"Add WebSocket reconnection logic", "Handle disconnections gracefully with exponential backoff.", "in_review", "medium", 6},
{"Set up CI/CD pipeline", "Configure GitHub Actions for automated testing and deployment.", "done", "high", 7},
{"Design database schema", "Create the initial PostgreSQL schema for all entities.", "done", "urgent", 8},
{"Implement real-time notifications", "Push notifications to users via WebSocket when issues change.", "todo", "medium", 9},
{"Agent task queue management", "Build the task dispatching and queue management system for agents.", "todo", "high", 10},
}
for _, iss := range issues {
var issueID string
// Check if issue already exists
err = pool.QueryRow(ctx, `
SELECT id FROM issue WHERE workspace_id = $1 AND title = $2
`, workspaceID, iss.title).Scan(&issueID)
if err == nil {
fmt.Printf("Issue exists: %s (%s)\n", iss.title, issueID)
continue
}
err = pool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, description, status, priority, creator_type, creator_id, position)
VALUES ($1, $2, $3, $4, $5, 'member', $6, $7)
RETURNING id
`, workspaceID, iss.title, iss.description, iss.status, iss.priority, userID, iss.position).Scan(&issueID)
if err != nil {
log.Printf("Failed to create issue %s: %v", iss.title, err)
continue
}
fmt.Printf("Issue created: %s (%s)\n", iss.title, issueID)
}
// Create seed comment (only if not already present)
var commentExists bool
_ = pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM comment c
JOIN issue i ON c.issue_id = i.id
WHERE i.workspace_id = $1 AND i.title = 'Implement issue list API endpoint'
AND c.content = 'This is a high priority item for Q2.'
)
`, workspaceID).Scan(&commentExists)
if !commentExists {
_, err = pool.Exec(ctx, `
INSERT INTO comment (issue_id, author_type, author_id, content, type)
SELECT i.id, 'member', $2, 'This is a high priority item for Q2.', 'comment'
FROM issue i WHERE i.workspace_id = $1 AND i.title = 'Implement issue list API endpoint'
`, workspaceID, userID)
if err != nil {
log.Printf("Failed to create comment: %v", err)
}
}
fmt.Println("\nSeed data created successfully!")
fmt.Printf("\nUser ID: %s\n", userID)
fmt.Printf("Workspace ID: %s\n", workspaceID)
}

View file

@ -0,0 +1,618 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/multica-ai/multica/server/internal/realtime"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
var (
testServer *httptest.Server
testToken string
testUserID string
testWorkspaceID string
)
var jwtSecret = []byte("multica-dev-secret-change-in-production")
func TestMain(m *testing.M) {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
}
pool, err := pgxpool.New(context.Background(), dbURL)
if err != nil {
fmt.Printf("Skipping integration tests: could not connect to database: %v\n", err)
os.Exit(0)
}
defer pool.Close()
// Get seed data IDs
row := pool.QueryRow(context.Background(), `SELECT id FROM "user" WHERE email = 'jiayuan@multica.ai'`)
row.Scan(&testUserID)
row = pool.QueryRow(context.Background(), `SELECT id FROM workspace WHERE slug = 'multica'`)
row.Scan(&testWorkspaceID)
if testUserID == "" || testWorkspaceID == "" {
fmt.Println("Skipping integration tests: seed data not found. Run 'go run ./cmd/seed/' first.")
os.Exit(0)
}
queries := db.New(pool)
hub := realtime.NewHub()
go hub.Run()
router := NewRouter(queries, hub)
testServer = httptest.NewServer(router)
defer testServer.Close()
// Login to get a real JWT token
loginBody, _ := json.Marshal(map[string]string{
"email": "jiayuan@multica.ai",
"name": "Jiayuan Zhang",
})
resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(loginBody))
if err != nil {
fmt.Printf("Skipping: login failed: %v\n", err)
os.Exit(0)
}
defer resp.Body.Close()
var loginResp struct {
Token string `json:"token"`
User struct {
ID string `json:"id"`
} `json:"user"`
}
json.NewDecoder(resp.Body).Decode(&loginResp)
testToken = loginResp.Token
os.Exit(m.Run())
}
// Helper to make authenticated requests
func authRequest(t *testing.T, method, path string, body any) *http.Response {
t.Helper()
var bodyReader io.Reader
if body != nil {
b, _ := json.Marshal(body)
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequest(method, testServer.URL+path, bodyReader)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+testToken)
req.Header.Set("X-Workspace-ID", testWorkspaceID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
return resp
}
func readJSON(t *testing.T, resp *http.Response, v any) {
t.Helper()
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
}
// ---- Health ----
func TestHealth(t *testing.T) {
resp, err := http.Get(testServer.URL + "/health")
if err != nil {
t.Fatalf("health check failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var result map[string]string
json.NewDecoder(resp.Body).Decode(&result)
if result["status"] != "ok" {
t.Fatalf("expected status ok, got %s", result["status"])
}
}
// ---- Auth ----
func TestLoginAndGetMe(t *testing.T) {
// Login
body, _ := json.Marshal(map[string]string{
"email": "integration-test@multica.ai",
"name": "Integration Tester",
})
resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("login failed: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var loginResp struct {
Token string `json:"token"`
User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
} `json:"user"`
}
readJSON(t, resp, &loginResp)
if loginResp.Token == "" {
t.Fatal("expected non-empty token")
}
if loginResp.User.Email != "integration-test@multica.ai" {
t.Fatalf("expected email 'integration-test@multica.ai', got '%s'", loginResp.User.Email)
}
// Use token to call /api/me
req, _ := http.NewRequest("GET", testServer.URL+"/api/me", nil)
req.Header.Set("Authorization", "Bearer "+loginResp.Token)
meResp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("getMe failed: %v", err)
}
if meResp.StatusCode != 200 {
t.Fatalf("expected 200, got %d", meResp.StatusCode)
}
var me struct {
Email string `json:"email"`
Name string `json:"name"`
}
readJSON(t, meResp, &me)
if me.Email != "integration-test@multica.ai" {
t.Fatalf("expected email 'integration-test@multica.ai', got '%s'", me.Email)
}
}
func TestProtectedRoutesRequireAuth(t *testing.T) {
paths := []string{"/api/me", "/api/issues", "/api/agents", "/api/inbox", "/api/workspaces"}
for _, path := range paths {
resp, err := http.Get(testServer.URL + path)
if err != nil {
t.Fatalf("request to %s failed: %v", path, err)
}
resp.Body.Close()
if resp.StatusCode != 401 {
t.Fatalf("%s: expected 401, got %d", path, resp.StatusCode)
}
}
}
func TestInvalidJWT(t *testing.T) {
cases := []struct {
name string
token string
}{
{"garbage token", "not-a-jwt"},
{"empty token", ""},
{"wrong secret", func() string {
claims := jwt.MapClaims{"sub": "test", "exp": time.Now().Add(time.Hour).Unix()}
t, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("wrong"))
return t
}()},
{"expired token", func() string {
claims := jwt.MapClaims{"sub": "test", "exp": time.Now().Add(-time.Hour).Unix()}
t, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret)
return t
}()},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req, _ := http.NewRequest("GET", testServer.URL+"/api/me", nil)
if tc.token != "" {
req.Header.Set("Authorization", "Bearer "+tc.token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
if resp.StatusCode != 401 {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
})
}
}
// ---- Issues CRUD through full router ----
func TestIssuesCRUDThroughRouter(t *testing.T) {
// Create
resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Integration test issue",
"status": "todo",
"priority": "high",
})
if resp.StatusCode != 201 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("CreateIssue: expected 201, got %d: %s", resp.StatusCode, body)
}
var created map[string]any
readJSON(t, resp, &created)
issueID := created["id"].(string)
if created["title"] != "Integration test issue" {
t.Fatalf("expected title 'Integration test issue', got '%s'", created["title"])
}
// Get
resp = authRequest(t, "GET", "/api/issues/"+issueID, nil)
if resp.StatusCode != 200 {
t.Fatalf("GetIssue: expected 200, got %d", resp.StatusCode)
}
var fetched map[string]any
readJSON(t, resp, &fetched)
if fetched["id"] != issueID {
t.Fatalf("expected id %s, got %s", issueID, fetched["id"])
}
// Update status only — should preserve title
resp = authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
"status": "in_progress",
})
if resp.StatusCode != 200 {
t.Fatalf("UpdateIssue: expected 200, got %d", resp.StatusCode)
}
var updated map[string]any
readJSON(t, resp, &updated)
if updated["status"] != "in_progress" {
t.Fatalf("expected status 'in_progress', got '%s'", updated["status"])
}
if updated["title"] != "Integration test issue" {
t.Fatalf("title should be preserved, got '%s'", updated["title"])
}
// Update title only — should preserve status
resp = authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
"title": "Renamed integration issue",
})
if resp.StatusCode != 200 {
t.Fatalf("UpdateIssue title: expected 200, got %d", resp.StatusCode)
}
var updated2 map[string]any
readJSON(t, resp, &updated2)
if updated2["title"] != "Renamed integration issue" {
t.Fatalf("expected title 'Renamed integration issue', got '%s'", updated2["title"])
}
if updated2["status"] != "in_progress" {
t.Fatalf("status should be preserved, got '%s'", updated2["status"])
}
// List
resp = authRequest(t, "GET", "/api/issues?workspace_id="+testWorkspaceID, nil)
if resp.StatusCode != 200 {
t.Fatalf("ListIssues: expected 200, got %d", resp.StatusCode)
}
var listResp map[string]any
readJSON(t, resp, &listResp)
total := listResp["total"].(float64)
if total < 1 {
t.Fatal("expected at least 1 issue")
}
// Delete
resp = authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
resp.Body.Close()
if resp.StatusCode != 204 {
t.Fatalf("DeleteIssue: expected 204, got %d", resp.StatusCode)
}
// Verify deleted
resp = authRequest(t, "GET", "/api/issues/"+issueID, nil)
resp.Body.Close()
if resp.StatusCode != 404 {
t.Fatalf("GetIssue after delete: expected 404, got %d", resp.StatusCode)
}
}
// ---- Comments through full router ----
func TestCommentsThroughRouter(t *testing.T) {
// Create issue
resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Comment integration test",
})
var issue map[string]any
readJSON(t, resp, &issue)
issueID := issue["id"].(string)
// Create comment
resp = authRequest(t, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "Integration test comment",
"type": "comment",
})
if resp.StatusCode != 201 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("CreateComment: expected 201, got %d: %s", resp.StatusCode, body)
}
var comment map[string]any
readJSON(t, resp, &comment)
if comment["content"] != "Integration test comment" {
t.Fatalf("expected content 'Integration test comment', got '%s'", comment["content"])
}
// Create second comment
resp = authRequest(t, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "Second comment",
"type": "comment",
})
resp.Body.Close()
// List comments
resp = authRequest(t, "GET", "/api/issues/"+issueID+"/comments", nil)
if resp.StatusCode != 200 {
t.Fatalf("ListComments: expected 200, got %d", resp.StatusCode)
}
var comments []map[string]any
readJSON(t, resp, &comments)
if len(comments) != 2 {
t.Fatalf("expected 2 comments, got %d", len(comments))
}
// Cleanup
resp = authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
resp.Body.Close()
}
// ---- Agents through full router ----
func TestAgentsThroughRouter(t *testing.T) {
// List
resp := authRequest(t, "GET", "/api/agents?workspace_id="+testWorkspaceID, nil)
if resp.StatusCode != 200 {
t.Fatalf("ListAgents: expected 200, got %d", resp.StatusCode)
}
var agents []map[string]any
readJSON(t, resp, &agents)
if len(agents) < 1 {
t.Fatal("expected at least 1 agent")
}
// Get
agentID := agents[0]["id"].(string)
resp = authRequest(t, "GET", "/api/agents/"+agentID, nil)
if resp.StatusCode != 200 {
t.Fatalf("GetAgent: expected 200, got %d", resp.StatusCode)
}
var agent map[string]any
readJSON(t, resp, &agent)
if agent["id"] != agentID {
t.Fatalf("expected agent id %s, got %s", agentID, agent["id"])
}
// Update status
resp = authRequest(t, "PUT", "/api/agents/"+agentID, map[string]any{
"status": "idle",
})
if resp.StatusCode != 200 {
t.Fatalf("UpdateAgent: expected 200, got %d", resp.StatusCode)
}
var updated map[string]any
readJSON(t, resp, &updated)
if updated["status"] != "idle" {
t.Fatalf("expected status 'idle', got '%s'", updated["status"])
}
// Name should be preserved
if updated["name"] != agents[0]["name"] {
t.Fatalf("name should be preserved, got '%s'", updated["name"])
}
}
// ---- Workspaces through full router ----
func TestWorkspacesThroughRouter(t *testing.T) {
// List
resp := authRequest(t, "GET", "/api/workspaces", nil)
if resp.StatusCode != 200 {
t.Fatalf("ListWorkspaces: expected 200, got %d", resp.StatusCode)
}
var workspaces []map[string]any
readJSON(t, resp, &workspaces)
if len(workspaces) < 1 {
t.Fatal("expected at least 1 workspace")
}
// Get
wsID := workspaces[0]["id"].(string)
resp = authRequest(t, "GET", "/api/workspaces/"+wsID, nil)
if resp.StatusCode != 200 {
t.Fatalf("GetWorkspace: expected 200, got %d", resp.StatusCode)
}
var ws map[string]any
readJSON(t, resp, &ws)
if ws["id"] != wsID {
t.Fatalf("expected workspace id %s, got %s", wsID, ws["id"])
}
// Update
resp = authRequest(t, "PUT", "/api/workspaces/"+wsID, map[string]any{
"description": "Integration test update",
})
if resp.StatusCode != 200 {
t.Fatalf("UpdateWorkspace: expected 200, got %d", resp.StatusCode)
}
var updated map[string]any
readJSON(t, resp, &updated)
if updated["description"] != "Integration test update" {
t.Fatalf("expected description 'Integration test update', got '%v'", updated["description"])
}
// Name should be preserved
if updated["name"] != ws["name"] {
t.Fatalf("name should be preserved")
}
// Members
resp = authRequest(t, "GET", "/api/workspaces/"+wsID+"/members", nil)
if resp.StatusCode != 200 {
t.Fatalf("ListMembers: expected 200, got %d", resp.StatusCode)
}
var members []map[string]any
readJSON(t, resp, &members)
if len(members) < 1 {
t.Fatal("expected at least 1 member")
}
// Verify member has user info
if members[0]["email"] == nil || members[0]["email"] == "" {
t.Fatal("member should have email field")
}
if members[0]["role"] == nil || members[0]["role"] == "" {
t.Fatal("member should have role field")
}
}
// ---- Inbox through full router ----
func TestInboxThroughRouter(t *testing.T) {
resp := authRequest(t, "GET", "/api/inbox", nil)
if resp.StatusCode != 200 {
t.Fatalf("ListInbox: expected 200, got %d", resp.StatusCode)
}
var items []map[string]any
readJSON(t, resp, &items)
// Inbox may be empty, just verify it returns valid JSON array
if items == nil {
t.Fatal("expected non-nil inbox items array")
}
}
// ---- 404 for non-existent resources ----
func TestNonExistentResources(t *testing.T) {
fakeUUID := "00000000-0000-0000-0000-000000000000"
cases := []struct {
name string
path string
}{
{"issue", "/api/issues/" + fakeUUID},
{"agent", "/api/agents/" + fakeUUID},
{"workspace", "/api/workspaces/" + fakeUUID},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp := authRequest(t, "GET", tc.path, nil)
resp.Body.Close()
if resp.StatusCode != 404 {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
})
}
}
// ---- Invalid request bodies ----
func TestInvalidRequestBodies(t *testing.T) {
resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, nil)
defer resp.Body.Close()
// Sending nil body should fail with 400
if resp.StatusCode != 400 {
// Some handlers may return 500 for nil body, that's acceptable too
if resp.StatusCode != 500 {
t.Fatalf("expected 400 or 500, got %d", resp.StatusCode)
}
}
}
// ---- WebSocket integration through full router ----
func TestWebSocketIntegration(t *testing.T) {
// Connect WebSocket client
wsURL := "ws" + strings.TrimPrefix(testServer.URL, "http") + "/ws"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("WebSocket connection failed: %v", err)
}
defer conn.Close()
// Create an issue — this should trigger a WebSocket broadcast
resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "WebSocket test issue",
"status": "todo",
})
var issue map[string]any
readJSON(t, resp, &issue)
issueID := issue["id"].(string)
// Read the WebSocket message
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("WebSocket read error: %v", err)
}
// Verify the message contains the issue event
var wsMsg map[string]any
if err := json.Unmarshal(msg, &wsMsg); err != nil {
t.Fatalf("failed to parse WebSocket message: %v", err)
}
if wsMsg["type"] != "issue:created" {
t.Fatalf("expected type 'issue:created', got '%s'", wsMsg["type"])
}
// Update the issue — should trigger another broadcast
resp = authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
"status": "in_progress",
})
resp.Body.Close()
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
_, msg, err = conn.ReadMessage()
if err != nil {
t.Fatalf("WebSocket read error on update: %v", err)
}
var updateMsg map[string]any
json.Unmarshal(msg, &updateMsg)
if updateMsg["type"] != "issue:updated" {
t.Fatalf("expected type 'issue:updated', got '%s'", updateMsg["type"])
}
// Delete the issue — should trigger another broadcast
resp = authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
resp.Body.Close()
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
_, msg, err = conn.ReadMessage()
if err != nil {
t.Fatalf("WebSocket read error on delete: %v", err)
}
var deleteMsg map[string]any
json.Unmarshal(msg, &deleteMsg)
if deleteMsg["type"] != "issue:deleted" {
t.Fatalf("expected type 'issue:deleted', got '%s'", deleteMsg["type"])
}
}

View file

@ -9,11 +9,10 @@ import (
"syscall"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/multica-ai/multica/server/internal/realtime"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func main() {
@ -22,82 +21,29 @@ func main() {
port = "8080"
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
}
// Connect to database
ctx := context.Background()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
log.Fatalf("Unable to connect to database: %v", err)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
log.Fatalf("Unable to ping database: %v", err)
}
log.Println("Connected to database")
queries := db.New(pool)
hub := realtime.NewHub()
go hub.Run()
r := chi.NewRouter()
// Global middleware
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(chimw.RequestID)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: true,
MaxAge: 300,
}))
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"ok"}`))
})
// WebSocket
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
realtime.HandleWebSocket(hub, w, r)
})
// Protected API routes
r.Group(func(r chi.Router) {
r.Use(middleware.Auth)
// Issues
r.Route("/api/issues", func(r chi.Router) {
r.Get("/", placeholder("list issues"))
r.Post("/", placeholder("create issue"))
r.Route("/{id}", func(r chi.Router) {
r.Get("/", placeholder("get issue"))
r.Put("/", placeholder("update issue"))
r.Delete("/", placeholder("delete issue"))
r.Post("/comments", placeholder("add comment"))
r.Get("/comments", placeholder("list comments"))
})
})
// Agents
r.Route("/api/agents", func(r chi.Router) {
r.Get("/", placeholder("list agents"))
r.Post("/", placeholder("create agent"))
r.Route("/{id}", func(r chi.Router) {
r.Get("/", placeholder("get agent"))
r.Put("/", placeholder("update agent"))
r.Get("/tasks", placeholder("list agent tasks"))
})
})
// Inbox
r.Route("/api/inbox", func(r chi.Router) {
r.Get("/", placeholder("list inbox"))
r.Post("/{id}/read", placeholder("mark read"))
r.Post("/{id}/archive", placeholder("archive"))
})
// Workspaces
r.Route("/api/workspaces", func(r chi.Router) {
r.Get("/", placeholder("list workspaces"))
r.Post("/", placeholder("create workspace"))
r.Route("/{id}", func(r chi.Router) {
r.Get("/", placeholder("get workspace"))
r.Put("/", placeholder("update workspace"))
})
})
})
// Auth (public)
r.Post("/auth/login", placeholder("login"))
r.Get("/auth/callback", placeholder("oauth callback"))
r := NewRouter(queries, hub)
srv := &http.Server{
Addr: ":" + port,
@ -117,19 +63,11 @@ func main() {
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server stopped")
}
func placeholder(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotImplemented)
w.Write([]byte(`{"error":"not implemented","endpoint":"` + name + `"}`))
}
}

View file

@ -0,0 +1,98 @@
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/multica-ai/multica/server/internal/handler"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/multica-ai/multica/server/internal/realtime"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// NewRouter creates the fully-configured Chi router with all middleware and routes.
func NewRouter(queries *db.Queries, hub *realtime.Hub) chi.Router {
h := handler.New(queries, hub)
r := chi.NewRouter()
// Global middleware
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(chimw.RequestID)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID"},
AllowCredentials: true,
MaxAge: 300,
}))
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
// WebSocket
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
realtime.HandleWebSocket(hub, w, r)
})
// Auth (public)
r.Post("/auth/login", h.Login)
// Protected API routes
r.Group(func(r chi.Router) {
r.Use(middleware.Auth)
// Auth
r.Get("/api/me", h.GetMe)
// Issues
r.Route("/api/issues", func(r chi.Router) {
r.Get("/", h.ListIssues)
r.Post("/", h.CreateIssue)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", h.GetIssue)
r.Put("/", h.UpdateIssue)
r.Delete("/", h.DeleteIssue)
r.Post("/comments", h.CreateComment)
r.Get("/comments", h.ListComments)
})
})
// Agents
r.Route("/api/agents", func(r chi.Router) {
r.Get("/", h.ListAgents)
r.Post("/", h.CreateAgent)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", h.GetAgent)
r.Put("/", h.UpdateAgent)
})
})
// Inbox
r.Route("/api/inbox", func(r chi.Router) {
r.Get("/", h.ListInbox)
r.Post("/{id}/read", h.MarkInboxRead)
r.Post("/{id}/archive", h.ArchiveInboxItem)
})
// Workspaces
r.Route("/api/workspaces", func(r chi.Router) {
r.Get("/", h.ListWorkspaces)
r.Post("/", h.CreateWorkspace)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", h.GetWorkspace)
r.Put("/", h.UpdateWorkspace)
r.Get("/members", h.ListMembersWithUser)
})
})
})
return r
}

View file

@ -7,3 +7,14 @@ require (
github.com/go-chi/cors v1.2.2
github.com/gorilla/websocket v1.5.3
)
require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
)

View file

@ -1,6 +1,29 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,194 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type AgentResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
RuntimeMode string `json:"runtime_mode"`
RuntimeConfig any `json:"runtime_config"`
Visibility string `json:"visibility"`
Status string `json:"status"`
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
OwnerID *string `json:"owner_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func agentToResponse(a db.Agent) AgentResponse {
var rc any
if a.RuntimeConfig != nil {
json.Unmarshal(a.RuntimeConfig, &rc)
}
if rc == nil {
rc = map[string]any{}
}
return AgentResponse{
ID: uuidToString(a.ID),
WorkspaceID: uuidToString(a.WorkspaceID),
Name: a.Name,
AvatarURL: textToPtr(a.AvatarUrl),
RuntimeMode: a.RuntimeMode,
RuntimeConfig: rc,
Visibility: a.Visibility,
Status: a.Status,
MaxConcurrentTasks: a.MaxConcurrentTasks,
OwnerID: uuidToPtr(a.OwnerID),
CreatedAt: timestampToString(a.CreatedAt),
UpdatedAt: timestampToString(a.UpdatedAt),
}
}
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
workspaceID := r.URL.Query().Get("workspace_id")
if workspaceID == "" {
workspaceID = r.Header.Get("X-Workspace-ID")
}
if workspaceID == "" {
writeError(w, http.StatusBadRequest, "workspace_id is required")
return
}
agents, err := h.Queries.ListAgents(r.Context(), parseUUID(workspaceID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list agents")
return
}
resp := make([]AgentResponse, len(agents))
for i, a := range agents {
resp[i] = agentToResponse(a)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
agent, err := h.Queries.GetAgent(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusNotFound, "agent not found")
return
}
writeJSON(w, http.StatusOK, agentToResponse(agent))
}
type CreateAgentRequest struct {
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
RuntimeMode string `json:"runtime_mode"`
RuntimeConfig any `json:"runtime_config"`
Visibility string `json:"visibility"`
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
}
func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
var req CreateAgentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
workspaceID := r.URL.Query().Get("workspace_id")
if workspaceID == "" {
workspaceID = r.Header.Get("X-Workspace-ID")
}
ownerID := r.Header.Get("X-User-ID")
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if req.RuntimeMode == "" {
req.RuntimeMode = "local"
}
if req.Visibility == "" {
req.Visibility = "workspace"
}
if req.MaxConcurrentTasks == 0 {
req.MaxConcurrentTasks = 1
}
rc, _ := json.Marshal(req.RuntimeConfig)
if req.RuntimeConfig == nil {
rc = []byte("{}")
}
agent, err := h.Queries.CreateAgent(r.Context(), db.CreateAgentParams{
WorkspaceID: parseUUID(workspaceID),
Name: req.Name,
AvatarUrl: ptrToText(req.AvatarURL),
RuntimeMode: req.RuntimeMode,
RuntimeConfig: rc,
Visibility: req.Visibility,
MaxConcurrentTasks: req.MaxConcurrentTasks,
OwnerID: parseUUID(ownerID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create agent: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, agentToResponse(agent))
}
type UpdateAgentRequest struct {
Name *string `json:"name"`
AvatarURL *string `json:"avatar_url"`
RuntimeConfig any `json:"runtime_config"`
Visibility *string `json:"visibility"`
Status *string `json:"status"`
MaxConcurrentTasks *int32 `json:"max_concurrent_tasks"`
}
func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req UpdateAgentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
params := db.UpdateAgentParams{
ID: parseUUID(id),
}
if req.Name != nil {
params.Name = pgtype.Text{String: *req.Name, Valid: true}
}
if req.AvatarURL != nil {
params.AvatarUrl = pgtype.Text{String: *req.AvatarURL, Valid: true}
}
if req.RuntimeConfig != nil {
rc, _ := json.Marshal(req.RuntimeConfig)
params.RuntimeConfig = rc
}
if req.Visibility != nil {
params.Visibility = pgtype.Text{String: *req.Visibility, Valid: true}
}
if req.Status != nil {
params.Status = pgtype.Text{String: *req.Status, Valid: true}
}
if req.MaxConcurrentTasks != nil {
params.MaxConcurrentTasks = pgtype.Int4{Int32: *req.MaxConcurrentTasks, Valid: true}
}
agent, err := h.Queries.UpdateAgent(r.Context(), params)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update agent: "+err.Error())
return
}
resp := agentToResponse(agent)
h.broadcast("agent:status", map[string]any{"agent": resp})
writeJSON(w, http.StatusOK, resp)
}

View file

@ -0,0 +1,109 @@
package handler
import (
"encoding/json"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
var jwtSecret = []byte("multica-dev-secret-change-in-production")
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL *string `json:"avatar_url"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func userToResponse(u db.User) UserResponse {
return UserResponse{
ID: uuidToString(u.ID),
Name: u.Name,
Email: u.Email,
AvatarURL: textToPtr(u.AvatarUrl),
CreatedAt: timestampToString(u.CreatedAt),
UpdatedAt: timestampToString(u.UpdatedAt),
}
}
type LoginRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
type LoginResponse struct {
Token string `json:"token"`
User UserResponse `json:"user"`
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Email == "" {
writeError(w, http.StatusBadRequest, "email is required")
return
}
// Try to find existing user
user, err := h.Queries.GetUserByEmail(r.Context(), req.Email)
if err != nil {
// Create new user
name := req.Name
if name == "" {
name = req.Email
}
user, err = h.Queries.CreateUser(r.Context(), db.CreateUserParams{
Name: name,
Email: req.Email,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
return
}
}
// Generate JWT
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": uuidToString(user.ID),
"email": user.Email,
"name": user.Name,
"exp": time.Now().Add(72 * time.Hour).Unix(),
"iat": time.Now().Unix(),
})
tokenString, err := token.SignedString(jwtSecret)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
writeJSON(w, http.StatusOK, LoginResponse{
Token: tokenString,
User: userToResponse(user),
})
}
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
if userID == "" {
writeError(w, http.StatusUnauthorized, "user not authenticated")
return
}
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusNotFound, "user not found")
return
}
writeJSON(w, http.StatusOK, userToResponse(user))
}

View file

@ -0,0 +1,91 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type CommentResponse struct {
ID string `json:"id"`
IssueID string `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID string `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func commentToResponse(c db.Comment) CommentResponse {
return CommentResponse{
ID: uuidToString(c.ID),
IssueID: uuidToString(c.IssueID),
AuthorType: c.AuthorType,
AuthorID: uuidToString(c.AuthorID),
Content: c.Content,
Type: c.Type,
CreatedAt: timestampToString(c.CreatedAt),
UpdatedAt: timestampToString(c.UpdatedAt),
}
}
func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
comments, err := h.Queries.ListComments(r.Context(), parseUUID(issueID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
resp := make([]CommentResponse, len(comments))
for i, c := range comments {
resp[i] = commentToResponse(c)
}
writeJSON(w, http.StatusOK, resp)
}
type CreateCommentRequest struct {
Content string `json:"content"`
Type string `json:"type"`
}
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
userID := r.Header.Get("X-User-ID")
if userID == "" {
writeError(w, http.StatusUnauthorized, "user not authenticated")
return
}
var req CreateCommentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
return
}
if req.Type == "" {
req.Type = "comment"
}
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
IssueID: parseUUID(issueID),
AuthorType: "member",
AuthorID: parseUUID(userID),
Content: req.Content,
Type: req.Type,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create comment: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, commentToResponse(comment))
}

View file

@ -0,0 +1,114 @@
package handler
import (
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/internal/realtime"
)
type Handler struct {
Queries *db.Queries
Hub *realtime.Hub
}
func New(queries *db.Queries, hub *realtime.Hub) *Handler {
return &Handler{Queries: queries, Hub: hub}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func parseUUID(s string) pgtype.UUID {
var u pgtype.UUID
_ = u.Scan(s)
return u
}
func uuidToString(u pgtype.UUID) string {
if !u.Valid {
return ""
}
b := u.Bytes
dst := make([]byte, 36)
hex.Encode(dst[0:8], b[0:4])
dst[8] = '-'
hex.Encode(dst[9:13], b[4:6])
dst[13] = '-'
hex.Encode(dst[14:18], b[6:8])
dst[18] = '-'
hex.Encode(dst[19:23], b[8:10])
dst[23] = '-'
hex.Encode(dst[24:36], b[10:16])
return string(dst)
}
func textToPtr(t pgtype.Text) *string {
if !t.Valid {
return nil
}
return &t.String
}
func ptrToText(s *string) pgtype.Text {
if s == nil {
return pgtype.Text{}
}
return pgtype.Text{String: *s, Valid: true}
}
func strToText(s string) pgtype.Text {
if s == "" {
return pgtype.Text{}
}
return pgtype.Text{String: s, Valid: true}
}
func timestampToString(t pgtype.Timestamptz) string {
if !t.Valid {
return ""
}
return t.Time.Format(time.RFC3339)
}
func timestampToPtr(t pgtype.Timestamptz) *string {
if !t.Valid {
return nil
}
s := t.Time.Format(time.RFC3339)
return &s
}
func uuidToPtr(u pgtype.UUID) *string {
if !u.Valid {
return nil
}
s := uuidToString(u)
return &s
}
// broadcast sends a WebSocket event to all connected clients.
func (h *Handler) broadcast(eventType string, payload any) {
msg := map[string]any{
"type": eventType,
"payload": payload,
}
data, err := json.Marshal(msg)
if err != nil {
fmt.Printf("broadcast marshal error: %v\n", err)
return
}
h.Hub.Broadcast(data)
}

View file

@ -0,0 +1,310 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/internal/realtime"
)
var testHandler *Handler
var testUserID string
var testWorkspaceID string
var testToken string
func TestMain(m *testing.M) {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
}
pool, err := pgxpool.New(context.Background(), dbURL)
if err != nil {
fmt.Printf("Skipping tests: could not connect to database: %v\n", err)
os.Exit(0)
}
defer pool.Close()
queries := db.New(pool)
hub := realtime.NewHub()
go hub.Run()
testHandler = New(queries, hub)
// Get seed user and workspace IDs
row := pool.QueryRow(context.Background(), `SELECT id FROM "user" WHERE email = 'jiayuan@multica.ai'`)
row.Scan(&testUserID)
row = pool.QueryRow(context.Background(), `SELECT id FROM workspace WHERE slug = 'multica'`)
row.Scan(&testWorkspaceID)
if testUserID == "" || testWorkspaceID == "" {
fmt.Println("Skipping tests: seed data not found. Run 'go run ./cmd/seed/' first.")
os.Exit(0)
}
// Generate a test token
import_jwt(testUserID)
os.Exit(m.Run())
}
func import_jwt(userID string) {
// Simple token generation for tests using the login handler
// We'll just set the headers directly instead
testToken = userID // We'll use X-User-ID header directly
}
func newRequest(method, path string, body any) *http.Request {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", testUserID)
req.Header.Set("X-Workspace-ID", testWorkspaceID)
return req
}
func withURLParam(req *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
func TestIssueCRUD(t *testing.T) {
// Create
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Test issue from Go test",
"status": "todo",
"priority": "medium",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
if created.Title != "Test issue from Go test" {
t.Fatalf("CreateIssue: expected title 'Test issue from Go test', got '%s'", created.Title)
}
if created.Status != "todo" {
t.Fatalf("CreateIssue: expected status 'todo', got '%s'", created.Status)
}
issueID := created.ID
// Get
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.GetIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GetIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
var fetched IssueResponse
json.NewDecoder(w.Body).Decode(&fetched)
if fetched.ID != issueID {
t.Fatalf("GetIssue: expected id '%s', got '%s'", issueID, fetched.ID)
}
// Update - partial (only status)
w = httptest.NewRecorder()
status := "in_progress"
req = newRequest("PUT", "/api/issues/"+issueID, map[string]any{
"status": status,
})
req = withURLParam(req, "id", issueID)
testHandler.UpdateIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
var updated IssueResponse
json.NewDecoder(w.Body).Decode(&updated)
if updated.Status != "in_progress" {
t.Fatalf("UpdateIssue: expected status 'in_progress', got '%s'", updated.Status)
}
if updated.Title != "Test issue from Go test" {
t.Fatalf("UpdateIssue: title should be preserved, got '%s'", updated.Title)
}
if updated.Priority != "medium" {
t.Fatalf("UpdateIssue: priority should be preserved, got '%s'", updated.Priority)
}
// List
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues?workspace_id="+testWorkspaceID, nil)
testHandler.ListIssues(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListIssues: expected 200, got %d: %s", w.Code, w.Body.String())
}
var listResp map[string]any
json.NewDecoder(w.Body).Decode(&listResp)
issues := listResp["issues"].([]any)
if len(issues) == 0 {
t.Fatal("ListIssues: expected at least 1 issue")
}
// Delete
w = httptest.NewRecorder()
req = newRequest("DELETE", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.DeleteIssue(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("DeleteIssue: expected 204, got %d: %s", w.Code, w.Body.String())
}
// Verify deleted
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.GetIssue(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("GetIssue after delete: expected 404, got %d", w.Code)
}
}
func TestCommentCRUD(t *testing.T) {
// Create an issue first
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Comment test issue",
})
testHandler.CreateIssue(w, req)
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
// Create comment
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "Test comment from Go test",
})
req = withURLParam(req, "id", issueID)
testHandler.CreateComment(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
}
// List comments
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID+"/comments", nil)
req = withURLParam(req, "id", issueID)
testHandler.ListComments(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListComments: expected 200, got %d: %s", w.Code, w.Body.String())
}
var comments []CommentResponse
json.NewDecoder(w.Body).Decode(&comments)
if len(comments) != 1 {
t.Fatalf("ListComments: expected 1 comment, got %d", len(comments))
}
if comments[0].Content != "Test comment from Go test" {
t.Fatalf("ListComments: expected content 'Test comment from Go test', got '%s'", comments[0].Content)
}
// Cleanup
w = httptest.NewRecorder()
req = newRequest("DELETE", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.DeleteIssue(w, req)
}
func TestAgentCRUD(t *testing.T) {
// List agents
w := httptest.NewRecorder()
req := newRequest("GET", "/api/agents?workspace_id="+testWorkspaceID, nil)
testHandler.ListAgents(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListAgents: expected 200, got %d: %s", w.Code, w.Body.String())
}
var agents []AgentResponse
json.NewDecoder(w.Body).Decode(&agents)
if len(agents) == 0 {
t.Fatal("ListAgents: expected at least 1 agent")
}
// Update agent status
agentID := agents[0].ID
w = httptest.NewRecorder()
req = newRequest("PUT", "/api/agents/"+agentID, map[string]any{
"status": "idle",
})
req = withURLParam(req, "id", agentID)
testHandler.UpdateAgent(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
}
var updated AgentResponse
json.NewDecoder(w.Body).Decode(&updated)
if updated.Status != "idle" {
t.Fatalf("UpdateAgent: expected status 'idle', got '%s'", updated.Status)
}
if updated.Name != agents[0].Name {
t.Fatalf("UpdateAgent: name should be preserved, got '%s'", updated.Name)
}
}
func TestWorkspaceCRUD(t *testing.T) {
// List workspaces
w := httptest.NewRecorder()
req := newRequest("GET", "/api/workspaces", nil)
testHandler.ListWorkspaces(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListWorkspaces: expected 200, got %d: %s", w.Code, w.Body.String())
}
var workspaces []WorkspaceResponse
json.NewDecoder(w.Body).Decode(&workspaces)
if len(workspaces) == 0 {
t.Fatal("ListWorkspaces: expected at least 1 workspace")
}
// Get workspace
wsID := workspaces[0].ID
w = httptest.NewRecorder()
req = newRequest("GET", "/api/workspaces/"+wsID, nil)
req = withURLParam(req, "id", wsID)
testHandler.GetWorkspace(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GetWorkspace: expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestAuthLogin(t *testing.T) {
w := httptest.NewRecorder()
body := map[string]string{"email": "test-handler@multica.ai", "name": "Test User"}
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(body)
req := httptest.NewRequest("POST", "/auth/login", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.Login(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Login: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp LoginResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Token == "" {
t.Fatal("Login: expected non-empty token")
}
if resp.User.Email != "test-handler@multica.ai" {
t.Fatalf("Login: expected email 'test-handler@multica.ai', got '%s'", resp.User.Email)
}
}

View file

@ -0,0 +1,100 @@
package handler
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type InboxItemResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
RecipientType string `json:"recipient_type"`
RecipientID string `json:"recipient_id"`
Type string `json:"type"`
Severity string `json:"severity"`
IssueID *string `json:"issue_id"`
Title string `json:"title"`
Body *string `json:"body"`
Read bool `json:"read"`
Archived bool `json:"archived"`
CreatedAt string `json:"created_at"`
}
func inboxToResponse(i db.InboxItem) InboxItemResponse {
return InboxItemResponse{
ID: uuidToString(i.ID),
WorkspaceID: uuidToString(i.WorkspaceID),
RecipientType: i.RecipientType,
RecipientID: uuidToString(i.RecipientID),
Type: i.Type,
Severity: i.Severity,
IssueID: uuidToPtr(i.IssueID),
Title: i.Title,
Body: textToPtr(i.Body),
Read: i.Read,
Archived: i.Archived,
CreatedAt: timestampToString(i.CreatedAt),
}
}
func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
if userID == "" {
writeError(w, http.StatusUnauthorized, "user not authenticated")
return
}
limit := 50
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil {
limit = v
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if v, err := strconv.Atoi(o); err == nil {
offset = v
}
}
items, err := h.Queries.ListInboxItems(r.Context(), db.ListInboxItemsParams{
RecipientType: "member",
RecipientID: parseUUID(userID),
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list inbox")
return
}
resp := make([]InboxItemResponse, len(items))
for i, item := range items {
resp[i] = inboxToResponse(item)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) MarkInboxRead(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
item, err := h.Queries.MarkInboxRead(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to mark read")
return
}
writeJSON(w, http.StatusOK, inboxToResponse(item))
}
func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
item, err := h.Queries.ArchiveInboxItem(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to archive")
return
}
writeJSON(w, http.StatusOK, inboxToResponse(item))
}

View file

@ -0,0 +1,341 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// IssueResponse is the JSON response for an issue.
type IssueResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID string `json:"creator_id"`
ParentIssueID *string `json:"parent_issue_id"`
AcceptanceCriteria []any `json:"acceptance_criteria"`
ContextRefs []any `json:"context_refs"`
Repository any `json:"repository"`
Position float64 `json:"position"`
DueDate *string `json:"due_date"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func issueToResponse(i db.Issue) IssueResponse {
var ac []any
if i.AcceptanceCriteria != nil {
json.Unmarshal(i.AcceptanceCriteria, &ac)
}
if ac == nil {
ac = []any{}
}
var cr []any
if i.ContextRefs != nil {
json.Unmarshal(i.ContextRefs, &cr)
}
if cr == nil {
cr = []any{}
}
var repo any
if i.Repository != nil {
json.Unmarshal(i.Repository, &repo)
}
return IssueResponse{
ID: uuidToString(i.ID),
WorkspaceID: uuidToString(i.WorkspaceID),
Title: i.Title,
Description: textToPtr(i.Description),
Status: i.Status,
Priority: i.Priority,
AssigneeType: textToPtr(i.AssigneeType),
AssigneeID: uuidToPtr(i.AssigneeID),
CreatorType: i.CreatorType,
CreatorID: uuidToString(i.CreatorID),
ParentIssueID: uuidToPtr(i.ParentIssueID),
AcceptanceCriteria: ac,
ContextRefs: cr,
Repository: repo,
Position: i.Position,
DueDate: timestampToPtr(i.DueDate),
CreatedAt: timestampToString(i.CreatedAt),
UpdatedAt: timestampToString(i.UpdatedAt),
}
}
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceID := r.URL.Query().Get("workspace_id")
if workspaceID == "" {
workspaceID = r.Header.Get("X-Workspace-ID")
}
if workspaceID == "" {
writeError(w, http.StatusBadRequest, "workspace_id is required")
return
}
limit := 100
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil {
limit = v
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if v, err := strconv.Atoi(o); err == nil {
offset = v
}
}
issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{
WorkspaceID: parseUUID(workspaceID),
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list issues")
return
}
resp := make([]IssueResponse, len(issues))
for i, issue := range issues {
resp[i] = issueToResponse(issue)
}
writeJSON(w, http.StatusOK, map[string]any{
"issues": resp,
"total": len(resp),
})
}
func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
issue, err := h.Queries.GetIssue(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusNotFound, "issue not found")
return
}
writeJSON(w, http.StatusOK, issueToResponse(issue))
}
type CreateIssueRequest struct {
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
ParentIssueID *string `json:"parent_issue_id"`
AcceptanceCriteria []any `json:"acceptance_criteria"`
ContextRefs []any `json:"context_refs"`
Repository any `json:"repository"`
}
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
var req CreateIssueRequest
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 := r.URL.Query().Get("workspace_id")
if workspaceID == "" {
workspaceID = r.Header.Get("X-Workspace-ID")
}
if workspaceID == "" {
writeError(w, http.StatusBadRequest, "workspace_id is required")
return
}
// Get creator from context (set by auth middleware)
creatorID := r.Header.Get("X-User-ID")
if creatorID == "" {
writeError(w, http.StatusUnauthorized, "user not authenticated")
return
}
status := req.Status
if status == "" {
status = "backlog"
}
priority := req.Priority
if priority == "" {
priority = "none"
}
ac, _ := json.Marshal(req.AcceptanceCriteria)
if req.AcceptanceCriteria == nil {
ac = []byte("[]")
}
cr, _ := json.Marshal(req.ContextRefs)
if req.ContextRefs == nil {
cr = []byte("[]")
}
var repo []byte
if req.Repository != nil {
repo, _ = json.Marshal(req.Repository)
}
var assigneeType pgtype.Text
var assigneeID pgtype.UUID
if req.AssigneeType != nil {
assigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
}
if req.AssigneeID != nil {
assigneeID = parseUUID(*req.AssigneeID)
}
var parentIssueID pgtype.UUID
if req.ParentIssueID != nil {
parentIssueID = parseUUID(*req.ParentIssueID)
}
issue, err := h.Queries.CreateIssue(r.Context(), db.CreateIssueParams{
WorkspaceID: parseUUID(workspaceID),
Title: req.Title,
Description: ptrToText(req.Description),
Status: status,
Priority: priority,
AssigneeType: assigneeType,
AssigneeID: assigneeID,
CreatorType: "member",
CreatorID: parseUUID(creatorID),
ParentIssueID: parentIssueID,
AcceptanceCriteria: ac,
ContextRefs: cr,
Repository: repo,
Position: 0,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create issue: "+err.Error())
return
}
resp := issueToResponse(issue)
h.broadcast("issue:created", map[string]any{"issue": resp})
// Create inbox notification for assignee
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
inboxItem, err := h.Queries.CreateInboxItem(r.Context(), db.CreateInboxItemParams{
WorkspaceID: issue.WorkspaceID,
RecipientType: issue.AssigneeType.String,
RecipientID: issue.AssigneeID,
Type: "issue_assigned",
Severity: "action_required",
IssueID: issue.ID,
Title: "New issue assigned: " + issue.Title,
Body: ptrToText(req.Description),
})
if err == nil {
h.broadcast("inbox:new", map[string]any{"item": inboxToResponse(inboxItem)})
}
}
writeJSON(w, http.StatusCreated, resp)
}
type UpdateIssueRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
Priority *string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
Position *float64 `json:"position"`
}
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req UpdateIssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
params := db.UpdateIssueParams{
ID: parseUUID(id),
}
if req.Title != nil {
params.Title = pgtype.Text{String: *req.Title, Valid: true}
}
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
}
if req.Status != nil {
params.Status = pgtype.Text{String: *req.Status, Valid: true}
}
if req.Priority != nil {
params.Priority = pgtype.Text{String: *req.Priority, Valid: true}
}
if req.AssigneeType != nil {
params.AssigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
}
if req.AssigneeID != nil {
params.AssigneeID = parseUUID(*req.AssigneeID)
}
if req.Position != nil {
params.Position = pgtype.Float8{Float64: *req.Position, Valid: true}
}
issue, err := h.Queries.UpdateIssue(r.Context(), params)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update issue: "+err.Error())
return
}
resp := issueToResponse(issue)
h.broadcast("issue:updated", map[string]any{"issue": resp})
// If status changed, create a notification
if req.Status != nil {
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
inboxItem, err := h.Queries.CreateInboxItem(r.Context(), db.CreateInboxItemParams{
WorkspaceID: issue.WorkspaceID,
RecipientType: issue.AssigneeType.String,
RecipientID: issue.AssigneeID,
Type: "status_change",
Severity: "info",
IssueID: issue.ID,
Title: issue.Title + " moved to " + *req.Status,
})
if err == nil {
h.broadcast("inbox:new", map[string]any{"item": inboxToResponse(inboxItem)})
}
}
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
err := h.Queries.DeleteIssue(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete issue")
return
}
h.broadcast("issue:deleted", map[string]any{"issue_id": id})
w.WriteHeader(http.StatusNoContent)
}

View file

@ -0,0 +1,226 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type WorkspaceResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description *string `json:"description"`
Settings any `json:"settings"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func workspaceToResponse(w db.Workspace) WorkspaceResponse {
var settings any
if w.Settings != nil {
json.Unmarshal(w.Settings, &settings)
}
if settings == nil {
settings = map[string]any{}
}
return WorkspaceResponse{
ID: uuidToString(w.ID),
Name: w.Name,
Slug: w.Slug,
Description: textToPtr(w.Description),
Settings: settings,
CreatedAt: timestampToString(w.CreatedAt),
UpdatedAt: timestampToString(w.UpdatedAt),
}
}
type MemberResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
CreatedAt string `json:"created_at"`
}
func memberToResponse(m db.Member) MemberResponse {
return MemberResponse{
ID: uuidToString(m.ID),
WorkspaceID: uuidToString(m.WorkspaceID),
UserID: uuidToString(m.UserID),
Role: m.Role,
CreatedAt: timestampToString(m.CreatedAt),
}
}
func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
if userID == "" {
writeError(w, http.StatusUnauthorized, "user not authenticated")
return
}
workspaces, err := h.Queries.ListWorkspaces(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list workspaces")
return
}
resp := make([]WorkspaceResponse, len(workspaces))
for i, ws := range workspaces {
resp[i] = workspaceToResponse(ws)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) GetWorkspace(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusNotFound, "workspace not found")
return
}
writeJSON(w, http.StatusOK, workspaceToResponse(ws))
}
type CreateWorkspaceRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description *string `json:"description"`
}
func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
if userID == "" {
writeError(w, http.StatusUnauthorized, "user not authenticated")
return
}
var req CreateWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.Slug == "" {
writeError(w, http.StatusBadRequest, "name and slug are required")
return
}
ws, err := h.Queries.CreateWorkspace(r.Context(), db.CreateWorkspaceParams{
Name: req.Name,
Slug: req.Slug,
Description: ptrToText(req.Description),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create workspace: "+err.Error())
return
}
// Add creator as owner
_, err = h.Queries.CreateMember(r.Context(), db.CreateMemberParams{
WorkspaceID: ws.ID,
UserID: parseUUID(userID),
Role: "owner",
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to add owner: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, workspaceToResponse(ws))
}
type UpdateWorkspaceRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Settings any `json:"settings"`
}
func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req UpdateWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
params := db.UpdateWorkspaceParams{
ID: parseUUID(id),
}
if req.Name != nil {
params.Name = pgtype.Text{String: *req.Name, Valid: true}
}
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
}
if req.Settings != nil {
s, _ := json.Marshal(req.Settings)
params.Settings = s
}
ws, err := h.Queries.UpdateWorkspace(r.Context(), params)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update workspace: "+err.Error())
return
}
writeJSON(w, http.StatusOK, workspaceToResponse(ws))
}
func (h *Handler) ListMembers(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
members, err := h.Queries.ListMembers(r.Context(), parseUUID(workspaceID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list members")
return
}
resp := make([]MemberResponse, len(members))
for i, m := range members {
resp[i] = memberToResponse(m)
}
writeJSON(w, http.StatusOK, resp)
}
type MemberWithUserResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
CreatedAt string `json:"created_at"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL *string `json:"avatar_url"`
}
func (h *Handler) ListMembersWithUser(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
members, err := h.Queries.ListMembersWithUser(r.Context(), parseUUID(workspaceID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list members")
return
}
resp := make([]MemberWithUserResponse, len(members))
for i, m := range members {
resp[i] = MemberWithUserResponse{
ID: uuidToString(m.ID),
WorkspaceID: uuidToString(m.WorkspaceID),
UserID: uuidToString(m.UserID),
Role: m.Role,
CreatedAt: timestampToString(m.CreatedAt),
Name: m.UserName,
Email: m.UserEmail,
AvatarURL: textToPtr(m.UserAvatarUrl),
}
}
writeJSON(w, http.StatusOK, resp)
}

View file

@ -2,14 +2,53 @@ package middleware
import (
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
var jwtSecret = []byte("multica-dev-secret-change-in-production")
// Auth middleware validates JWT tokens from the Authorization header.
// TODO: Implement JWT validation.
// Sets X-User-ID and X-User-Email headers on the request for downstream handlers.
func Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: Extract and validate JWT from Authorization header
// For now, pass through all requests during development
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
return
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return jwtSecret, nil
})
if err != nil || !token.Valid {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
return
}
if sub, ok := claims["sub"].(string); ok {
r.Header.Set("X-User-ID", sub)
}
if email, ok := claims["email"].(string); ok {
r.Header.Set("X-User-Email", email)
}
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,185 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func generateToken(claims jwt.MapClaims, secret []byte) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
s, _ := token.SignedString(secret)
return s
}
func validClaims() jwt.MapClaims {
return jwt.MapClaims{
"sub": "test-user-id",
"email": "test@multica.ai",
"exp": time.Now().Add(time.Hour).Unix(),
}
}
func TestAuth_MissingHeader(t *testing.T) {
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}))
req := httptest.NewRequest("GET", "/api/me", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
if body := w.Body.String(); body != `{"error":"missing authorization header"}`+"\n" {
t.Fatalf("unexpected body: %s", body)
}
}
func TestAuth_NoBearerPrefix(t *testing.T) {
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}))
req := httptest.NewRequest("GET", "/api/me", nil)
req.Header.Set("Authorization", "Token some-token")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
if body := w.Body.String(); body != `{"error":"invalid authorization format"}`+"\n" {
t.Fatalf("unexpected body: %s", body)
}
}
func TestAuth_InvalidToken(t *testing.T) {
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}))
req := httptest.NewRequest("GET", "/api/me", nil)
req.Header.Set("Authorization", "Bearer not-a-valid-jwt")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestAuth_ExpiredToken(t *testing.T) {
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}))
claims := validClaims()
claims["exp"] = time.Now().Add(-time.Hour).Unix()
token := generateToken(claims, jwtSecret)
req := httptest.NewRequest("GET", "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestAuth_WrongSecret(t *testing.T) {
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}))
token := generateToken(validClaims(), []byte("wrong-secret"))
req := httptest.NewRequest("GET", "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestAuth_WrongSigningMethod(t *testing.T) {
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}))
// Use "none" signing method
token := jwt.NewWithClaims(jwt.SigningMethodNone, validClaims())
s, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
req := httptest.NewRequest("GET", "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+s)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestAuth_ValidToken(t *testing.T) {
var gotUserID, gotEmail string
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUserID = r.Header.Get("X-User-ID")
gotEmail = r.Header.Get("X-User-Email")
w.WriteHeader(http.StatusOK)
}))
token := generateToken(validClaims(), jwtSecret)
req := httptest.NewRequest("GET", "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotUserID != "test-user-id" {
t.Fatalf("expected X-User-ID 'test-user-id', got '%s'", gotUserID)
}
if gotEmail != "test@multica.ai" {
t.Fatalf("expected X-User-Email 'test@multica.ai', got '%s'", gotEmail)
}
}
func TestAuth_MissingClaims(t *testing.T) {
var gotUserID, gotEmail string
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUserID = r.Header.Get("X-User-ID")
gotEmail = r.Header.Get("X-User-Email")
w.WriteHeader(http.StatusOK)
}))
// Token with no sub or email claims, only exp
claims := jwt.MapClaims{
"exp": time.Now().Add(time.Hour).Unix(),
}
token := generateToken(claims, jwtSecret)
req := httptest.NewRequest("GET", "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotUserID != "" {
t.Fatalf("expected empty X-User-ID, got '%s'", gotUserID)
}
if gotEmail != "" {
t.Fatalf("expected empty X-User-Email, got '%s'", gotEmail)
}
}

View file

@ -0,0 +1,177 @@
package realtime
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
func newTestHub(t *testing.T) (*Hub, *httptest.Server) {
t.Helper()
hub := NewHub()
go hub.Run()
mux := http.NewServeMux()
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
HandleWebSocket(hub, w, r)
})
server := httptest.NewServer(mux)
return hub, server
}
func connectWS(t *testing.T, server *httptest.Server) *websocket.Conn {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("failed to connect WebSocket: %v", err)
}
return conn
}
func TestHub_ClientRegistration(t *testing.T) {
hub, server := newTestHub(t)
defer server.Close()
conn := connectWS(t, server)
defer conn.Close()
time.Sleep(50 * time.Millisecond)
hub.mu.RLock()
count := len(hub.clients)
hub.mu.RUnlock()
if count != 1 {
t.Fatalf("expected 1 client, got %d", count)
}
}
func TestHub_Broadcast(t *testing.T) {
hub, server := newTestHub(t)
defer server.Close()
conn1 := connectWS(t, server)
defer conn1.Close()
conn2 := connectWS(t, server)
defer conn2.Close()
time.Sleep(50 * time.Millisecond)
msg := []byte(`{"type":"issue:created","data":"test"}`)
hub.Broadcast(msg)
conn1.SetReadDeadline(time.Now().Add(2 * time.Second))
_, received1, err := conn1.ReadMessage()
if err != nil {
t.Fatalf("client 1 read error: %v", err)
}
if string(received1) != string(msg) {
t.Fatalf("client 1: expected %s, got %s", msg, received1)
}
conn2.SetReadDeadline(time.Now().Add(2 * time.Second))
_, received2, err := conn2.ReadMessage()
if err != nil {
t.Fatalf("client 2 read error: %v", err)
}
if string(received2) != string(msg) {
t.Fatalf("client 2: expected %s, got %s", msg, received2)
}
}
func TestHub_ClientDisconnect(t *testing.T) {
hub, server := newTestHub(t)
defer server.Close()
conn := connectWS(t, server)
time.Sleep(50 * time.Millisecond)
hub.mu.RLock()
countBefore := len(hub.clients)
hub.mu.RUnlock()
if countBefore != 1 {
t.Fatalf("expected 1 client before disconnect, got %d", countBefore)
}
conn.Close()
time.Sleep(100 * time.Millisecond)
hub.mu.RLock()
countAfter := len(hub.clients)
hub.mu.RUnlock()
if countAfter != 0 {
t.Fatalf("expected 0 clients after disconnect, got %d", countAfter)
}
}
func TestHub_BroadcastToMultipleClients(t *testing.T) {
hub, server := newTestHub(t)
defer server.Close()
const numClients = 5
conns := make([]*websocket.Conn, numClients)
for i := 0; i < numClients; i++ {
conns[i] = connectWS(t, server)
defer conns[i].Close()
}
time.Sleep(50 * time.Millisecond)
hub.mu.RLock()
count := len(hub.clients)
hub.mu.RUnlock()
if count != numClients {
t.Fatalf("expected %d clients, got %d", numClients, count)
}
msg := []byte(`{"type":"test","count":5}`)
hub.Broadcast(msg)
for i, conn := range conns {
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, received, err := conn.ReadMessage()
if err != nil {
t.Fatalf("client %d read error: %v", i, err)
}
if string(received) != string(msg) {
t.Fatalf("client %d: expected %s, got %s", i, msg, received)
}
}
}
func TestHub_MultipleBroadcasts(t *testing.T) {
hub, server := newTestHub(t)
defer server.Close()
conn := connectWS(t, server)
defer conn.Close()
time.Sleep(50 * time.Millisecond)
messages := []string{
`{"type":"issue:created"}`,
`{"type":"issue:updated"}`,
`{"type":"issue:deleted"}`,
}
for _, msg := range messages {
hub.Broadcast([]byte(msg))
}
for i, expected := range messages {
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, received, err := conn.ReadMessage()
if err != nil {
t.Fatalf("message %d read error: %v", i, err)
}
if string(received) != expected {
t.Fatalf("message %d: expected %s, got %s", i, expected, received)
}
}
}

View file

@ -0,0 +1,93 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: activity.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createActivity = `-- name: CreateActivity :one
INSERT INTO activity_log (
workspace_id, issue_id, actor_type, actor_id, action, details
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at
`
type CreateActivityParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
IssueID pgtype.UUID `json:"issue_id"`
ActorType pgtype.Text `json:"actor_type"`
ActorID pgtype.UUID `json:"actor_id"`
Action string `json:"action"`
Details []byte `json:"details"`
}
func (q *Queries) CreateActivity(ctx context.Context, arg CreateActivityParams) (ActivityLog, error) {
row := q.db.QueryRow(ctx, createActivity,
arg.WorkspaceID,
arg.IssueID,
arg.ActorType,
arg.ActorID,
arg.Action,
arg.Details,
)
var i ActivityLog
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.ActorType,
&i.ActorID,
&i.Action,
&i.Details,
&i.CreatedAt,
)
return i, err
}
const listActivities = `-- name: ListActivities :many
SELECT id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at FROM activity_log
WHERE issue_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
type ListActivitiesParams struct {
IssueID pgtype.UUID `json:"issue_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListActivities(ctx context.Context, arg ListActivitiesParams) ([]ActivityLog, error) {
rows, err := q.db.Query(ctx, listActivities, arg.IssueID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ActivityLog{}
for rows.Next() {
var i ActivityLog
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.ActorType,
&i.ActorID,
&i.Action,
&i.Details,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View file

@ -0,0 +1,184 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: agent.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createAgent = `-- name: CreateAgent :one
INSERT INTO agent (
workspace_id, name, avatar_url, runtime_mode,
runtime_config, visibility, max_concurrent_tasks, owner_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at
`
type CreateAgentParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name string `json:"name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
RuntimeMode string `json:"runtime_mode"`
RuntimeConfig []byte `json:"runtime_config"`
Visibility string `json:"visibility"`
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
OwnerID pgtype.UUID `json:"owner_id"`
}
func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent, error) {
row := q.db.QueryRow(ctx, createAgent,
arg.WorkspaceID,
arg.Name,
arg.AvatarUrl,
arg.RuntimeMode,
arg.RuntimeConfig,
arg.Visibility,
arg.MaxConcurrentTasks,
arg.OwnerID,
)
var i Agent
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.AvatarUrl,
&i.RuntimeMode,
&i.RuntimeConfig,
&i.Visibility,
&i.Status,
&i.MaxConcurrentTasks,
&i.OwnerID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteAgent = `-- name: DeleteAgent :exec
DELETE FROM agent WHERE id = $1
`
func (q *Queries) DeleteAgent(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteAgent, id)
return err
}
const getAgent = `-- name: GetAgent :one
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at FROM agent
WHERE id = $1
`
func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
row := q.db.QueryRow(ctx, getAgent, id)
var i Agent
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.AvatarUrl,
&i.RuntimeMode,
&i.RuntimeConfig,
&i.Visibility,
&i.Status,
&i.MaxConcurrentTasks,
&i.OwnerID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listAgents = `-- name: ListAgents :many
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at FROM agent
WHERE workspace_id = $1
ORDER BY created_at ASC
`
func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Agent, error) {
rows, err := q.db.Query(ctx, listAgents, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Agent{}
for rows.Next() {
var i Agent
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.AvatarUrl,
&i.RuntimeMode,
&i.RuntimeConfig,
&i.Visibility,
&i.Status,
&i.MaxConcurrentTasks,
&i.OwnerID,
&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 updateAgent = `-- name: UpdateAgent :one
UPDATE agent SET
name = COALESCE($2, name),
avatar_url = COALESCE($3, avatar_url),
runtime_config = COALESCE($4, runtime_config),
visibility = COALESCE($5, visibility),
status = COALESCE($6, status),
max_concurrent_tasks = COALESCE($7, max_concurrent_tasks),
updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at
`
type UpdateAgentParams struct {
ID pgtype.UUID `json:"id"`
Name pgtype.Text `json:"name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
RuntimeConfig []byte `json:"runtime_config"`
Visibility pgtype.Text `json:"visibility"`
Status pgtype.Text `json:"status"`
MaxConcurrentTasks pgtype.Int4 `json:"max_concurrent_tasks"`
}
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent, error) {
row := q.db.QueryRow(ctx, updateAgent,
arg.ID,
arg.Name,
arg.AvatarUrl,
arg.RuntimeConfig,
arg.Visibility,
arg.Status,
arg.MaxConcurrentTasks,
)
var i Agent
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.AvatarUrl,
&i.RuntimeMode,
&i.RuntimeConfig,
&i.Visibility,
&i.Status,
&i.MaxConcurrentTasks,
&i.OwnerID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View file

@ -0,0 +1,142 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: comment.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createComment = `-- name: CreateComment :one
INSERT INTO comment (issue_id, author_type, author_id, content, type)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at
`
type CreateCommentParams struct {
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
}
func (q *Queries) CreateComment(ctx context.Context, arg CreateCommentParams) (Comment, error) {
row := q.db.QueryRow(ctx, createComment,
arg.IssueID,
arg.AuthorType,
arg.AuthorID,
arg.Content,
arg.Type,
)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteComment = `-- name: DeleteComment :exec
DELETE FROM comment WHERE id = $1
`
func (q *Queries) DeleteComment(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteComment, id)
return err
}
const getComment = `-- name: GetComment :one
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at FROM comment
WHERE id = $1
`
func (q *Queries) GetComment(ctx context.Context, id pgtype.UUID) (Comment, error) {
row := q.db.QueryRow(ctx, getComment, id)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listComments = `-- name: ListComments :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at FROM comment
WHERE issue_id = $1
ORDER BY created_at ASC
`
func (q *Queries) ListComments(ctx context.Context, issueID pgtype.UUID) ([]Comment, error) {
rows, err := q.db.Query(ctx, listComments, issueID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&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 updateComment = `-- name: UpdateComment :one
UPDATE comment SET
content = $2,
updated_at = now()
WHERE id = $1
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at
`
type UpdateCommentParams struct {
ID pgtype.UUID `json:"id"`
Content string `json:"content"`
}
func (q *Queries) UpdateComment(ctx context.Context, arg UpdateCommentParams) (Comment, error) {
row := q.db.QueryRow(ctx, updateComment, arg.ID, arg.Content)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View file

@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package db
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}

View file

@ -0,0 +1,206 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: inbox.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const archiveInboxItem = `-- name: ArchiveInboxItem :one
UPDATE inbox_item SET archived = true
WHERE id = $1
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at
`
func (q *Queries) ArchiveInboxItem(ctx context.Context, id pgtype.UUID) (InboxItem, error) {
row := q.db.QueryRow(ctx, archiveInboxItem, id)
var i InboxItem
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.RecipientType,
&i.RecipientID,
&i.Type,
&i.Severity,
&i.IssueID,
&i.Title,
&i.Body,
&i.Read,
&i.Archived,
&i.CreatedAt,
)
return i, err
}
const countUnreadInbox = `-- name: CountUnreadInbox :one
SELECT count(*) FROM inbox_item
WHERE recipient_type = $1 AND recipient_id = $2 AND read = false AND archived = false
`
type CountUnreadInboxParams struct {
RecipientType string `json:"recipient_type"`
RecipientID pgtype.UUID `json:"recipient_id"`
}
func (q *Queries) CountUnreadInbox(ctx context.Context, arg CountUnreadInboxParams) (int64, error) {
row := q.db.QueryRow(ctx, countUnreadInbox, arg.RecipientType, arg.RecipientID)
var count int64
err := row.Scan(&count)
return count, err
}
const createInboxItem = `-- name: CreateInboxItem :one
INSERT INTO inbox_item (
workspace_id, recipient_type, recipient_id,
type, severity, issue_id, title, body
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at
`
type CreateInboxItemParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
RecipientType string `json:"recipient_type"`
RecipientID pgtype.UUID `json:"recipient_id"`
Type string `json:"type"`
Severity string `json:"severity"`
IssueID pgtype.UUID `json:"issue_id"`
Title string `json:"title"`
Body pgtype.Text `json:"body"`
}
func (q *Queries) CreateInboxItem(ctx context.Context, arg CreateInboxItemParams) (InboxItem, error) {
row := q.db.QueryRow(ctx, createInboxItem,
arg.WorkspaceID,
arg.RecipientType,
arg.RecipientID,
arg.Type,
arg.Severity,
arg.IssueID,
arg.Title,
arg.Body,
)
var i InboxItem
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.RecipientType,
&i.RecipientID,
&i.Type,
&i.Severity,
&i.IssueID,
&i.Title,
&i.Body,
&i.Read,
&i.Archived,
&i.CreatedAt,
)
return i, err
}
const getInboxItem = `-- name: GetInboxItem :one
SELECT id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at FROM inbox_item
WHERE id = $1
`
func (q *Queries) GetInboxItem(ctx context.Context, id pgtype.UUID) (InboxItem, error) {
row := q.db.QueryRow(ctx, getInboxItem, id)
var i InboxItem
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.RecipientType,
&i.RecipientID,
&i.Type,
&i.Severity,
&i.IssueID,
&i.Title,
&i.Body,
&i.Read,
&i.Archived,
&i.CreatedAt,
)
return i, err
}
const listInboxItems = `-- name: ListInboxItems :many
SELECT id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at FROM inbox_item
WHERE recipient_type = $1 AND recipient_id = $2 AND archived = false
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
`
type ListInboxItemsParams struct {
RecipientType string `json:"recipient_type"`
RecipientID pgtype.UUID `json:"recipient_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams) ([]InboxItem, error) {
rows, err := q.db.Query(ctx, listInboxItems,
arg.RecipientType,
arg.RecipientID,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []InboxItem{}
for rows.Next() {
var i InboxItem
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.RecipientType,
&i.RecipientID,
&i.Type,
&i.Severity,
&i.IssueID,
&i.Title,
&i.Body,
&i.Read,
&i.Archived,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markInboxRead = `-- name: MarkInboxRead :one
UPDATE inbox_item SET read = true
WHERE id = $1
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at
`
func (q *Queries) MarkInboxRead(ctx context.Context, id pgtype.UUID) (InboxItem, error) {
row := q.db.QueryRow(ctx, markInboxRead, id)
var i InboxItem
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.RecipientType,
&i.RecipientID,
&i.Type,
&i.Severity,
&i.IssueID,
&i.Title,
&i.Body,
&i.Read,
&i.Archived,
&i.CreatedAt,
)
return i, err
}

View file

@ -0,0 +1,233 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: issue.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
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, acceptance_criteria, context_refs,
repository, position
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, repository, position, due_date, created_at, updated_at
`
type CreateIssueParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID pgtype.UUID `json:"creator_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
AcceptanceCriteria []byte `json:"acceptance_criteria"`
ContextRefs []byte `json:"context_refs"`
Repository []byte `json:"repository"`
Position float64 `json:"position"`
}
func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) {
row := q.db.QueryRow(ctx, createIssue,
arg.WorkspaceID,
arg.Title,
arg.Description,
arg.Status,
arg.Priority,
arg.AssigneeType,
arg.AssigneeID,
arg.CreatorType,
arg.CreatorID,
arg.ParentIssueID,
arg.AcceptanceCriteria,
arg.ContextRefs,
arg.Repository,
arg.Position,
)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Repository,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteIssue = `-- name: DeleteIssue :exec
DELETE FROM issue WHERE id = $1
`
func (q *Queries) DeleteIssue(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteIssue, id)
return err
}
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, repository, position, due_date, created_at, updated_at FROM issue
WHERE id = $1
`
func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) {
row := q.db.QueryRow(ctx, getIssue, id)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Repository,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listIssues = `-- name: ListIssues :many
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, repository, position, due_date, created_at, updated_at FROM issue
WHERE workspace_id = $1
ORDER BY position ASC, created_at DESC
LIMIT $2 OFFSET $3
`
type ListIssuesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue, error) {
rows, err := q.db.Query(ctx, listIssues, arg.WorkspaceID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Issue{}
for rows.Next() {
var i Issue
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Repository,
&i.Position,
&i.DueDate,
&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 updateIssue = `-- name: UpdateIssue :one
UPDATE issue SET
title = COALESCE($2, title),
description = COALESCE($3, description),
status = COALESCE($4, status),
priority = COALESCE($5, priority),
assignee_type = $6,
assignee_id = $7,
position = COALESCE($8, position),
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, repository, position, due_date, created_at, updated_at
`
type UpdateIssueParams struct {
ID pgtype.UUID `json:"id"`
Title pgtype.Text `json:"title"`
Description pgtype.Text `json:"description"`
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
Position pgtype.Float8 `json:"position"`
}
func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) {
row := q.db.QueryRow(ctx, updateIssue,
arg.ID,
arg.Title,
arg.Description,
arg.Status,
arg.Priority,
arg.AssigneeType,
arg.AssigneeID,
arg.Position,
)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Repository,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View file

@ -0,0 +1,192 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: member.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createMember = `-- name: CreateMember :one
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, $3)
RETURNING id, workspace_id, user_id, role, created_at
`
type CreateMemberParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"`
}
func (q *Queries) CreateMember(ctx context.Context, arg CreateMemberParams) (Member, error) {
row := q.db.QueryRow(ctx, createMember, arg.WorkspaceID, arg.UserID, arg.Role)
var i Member
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
const deleteMember = `-- name: DeleteMember :exec
DELETE FROM member WHERE id = $1
`
func (q *Queries) DeleteMember(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteMember, id)
return err
}
const getMember = `-- name: GetMember :one
SELECT id, workspace_id, user_id, role, created_at FROM member
WHERE id = $1
`
func (q *Queries) GetMember(ctx context.Context, id pgtype.UUID) (Member, error) {
row := q.db.QueryRow(ctx, getMember, id)
var i Member
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
const getMemberByUserAndWorkspace = `-- name: GetMemberByUserAndWorkspace :one
SELECT id, workspace_id, user_id, role, created_at FROM member
WHERE user_id = $1 AND workspace_id = $2
`
type GetMemberByUserAndWorkspaceParams struct {
UserID pgtype.UUID `json:"user_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetMemberByUserAndWorkspace(ctx context.Context, arg GetMemberByUserAndWorkspaceParams) (Member, error) {
row := q.db.QueryRow(ctx, getMemberByUserAndWorkspace, arg.UserID, arg.WorkspaceID)
var i Member
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}
const listMembers = `-- name: ListMembers :many
SELECT id, workspace_id, user_id, role, created_at FROM member
WHERE workspace_id = $1
ORDER BY created_at ASC
`
func (q *Queries) ListMembers(ctx context.Context, workspaceID pgtype.UUID) ([]Member, error) {
rows, err := q.db.Query(ctx, listMembers, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Member{}
for rows.Next() {
var i Member
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.UserID,
&i.Role,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listMembersWithUser = `-- name: ListMembersWithUser :many
SELECT m.id, m.workspace_id, m.user_id, m.role, m.created_at,
u.name as user_name, u.email as user_email, u.avatar_url as user_avatar_url
FROM member m
JOIN "user" u ON u.id = m.user_id
WHERE m.workspace_id = $1
ORDER BY m.created_at ASC
`
type ListMembersWithUserRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
UserAvatarUrl pgtype.Text `json:"user_avatar_url"`
}
func (q *Queries) ListMembersWithUser(ctx context.Context, workspaceID pgtype.UUID) ([]ListMembersWithUserRow, error) {
rows, err := q.db.Query(ctx, listMembersWithUser, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListMembersWithUserRow{}
for rows.Next() {
var i ListMembersWithUserRow
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.UserID,
&i.Role,
&i.CreatedAt,
&i.UserName,
&i.UserEmail,
&i.UserAvatarUrl,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateMemberRole = `-- name: UpdateMemberRole :one
UPDATE member SET role = $2
WHERE id = $1
RETURNING id, workspace_id, user_id, role, created_at
`
type UpdateMemberRoleParams struct {
ID pgtype.UUID `json:"id"`
Role string `json:"role"`
}
func (q *Queries) UpdateMemberRole(ctx context.Context, arg UpdateMemberRoleParams) (Member, error) {
row := q.db.QueryRow(ctx, updateMemberRole, arg.ID, arg.Role)
var i Member
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.UserID,
&i.Role,
&i.CreatedAt,
)
return i, err
}

View file

@ -0,0 +1,153 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package db
import (
"github.com/jackc/pgx/v5/pgtype"
)
type ActivityLog struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
IssueID pgtype.UUID `json:"issue_id"`
ActorType pgtype.Text `json:"actor_type"`
ActorID pgtype.UUID `json:"actor_id"`
Action string `json:"action"`
Details []byte `json:"details"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Agent struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name string `json:"name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
RuntimeMode string `json:"runtime_mode"`
RuntimeConfig []byte `json:"runtime_config"`
Visibility string `json:"visibility"`
Status string `json:"status"`
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
OwnerID pgtype.UUID `json:"owner_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type AgentTaskQueue struct {
ID pgtype.UUID `json:"id"`
AgentID pgtype.UUID `json:"agent_id"`
IssueID pgtype.UUID `json:"issue_id"`
Status string `json:"status"`
Priority int32 `json:"priority"`
DispatchedAt pgtype.Timestamptz `json:"dispatched_at"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
Result []byte `json:"result"`
Error pgtype.Text `json:"error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Comment struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type DaemonConnection struct {
ID pgtype.UUID `json:"id"`
AgentID pgtype.UUID `json:"agent_id"`
DaemonID string `json:"daemon_id"`
Status string `json:"status"`
LastHeartbeatAt pgtype.Timestamptz `json:"last_heartbeat_at"`
RuntimeInfo []byte `json:"runtime_info"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type InboxItem struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
RecipientType string `json:"recipient_type"`
RecipientID pgtype.UUID `json:"recipient_id"`
Type string `json:"type"`
Severity string `json:"severity"`
IssueID pgtype.UUID `json:"issue_id"`
Title string `json:"title"`
Body pgtype.Text `json:"body"`
Read bool `json:"read"`
Archived bool `json:"archived"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Issue struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType pgtype.Text `json:"assignee_type"`
AssigneeID pgtype.UUID `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID pgtype.UUID `json:"creator_id"`
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
AcceptanceCriteria []byte `json:"acceptance_criteria"`
ContextRefs []byte `json:"context_refs"`
Repository []byte `json:"repository"`
Position float64 `json:"position"`
DueDate pgtype.Timestamptz `json:"due_date"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type IssueDependency struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
DependsOnIssueID pgtype.UUID `json:"depends_on_issue_id"`
Type string `json:"type"`
}
type IssueLabel struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name string `json:"name"`
Color string `json:"color"`
}
type IssueToLabel struct {
IssueID pgtype.UUID `json:"issue_id"`
LabelID pgtype.UUID `json:"label_id"`
}
type Member struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type User struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
AvatarUrl pgtype.Text `json:"avatar_url"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Workspace struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description pgtype.Text `json:"description"`
Settings []byte `json:"settings"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}

View file

@ -0,0 +1,105 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: user.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createUser = `-- name: CreateUser :one
INSERT INTO "user" (name, email, avatar_url)
VALUES ($1, $2, $3)
RETURNING id, name, email, avatar_url, created_at, updated_at
`
type CreateUserParams struct {
Name string `json:"name"`
Email string `json:"email"`
AvatarUrl pgtype.Text `json:"avatar_url"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRow(ctx, createUser, arg.Name, arg.Email, arg.AvatarUrl)
var i User
err := row.Scan(
&i.ID,
&i.Name,
&i.Email,
&i.AvatarUrl,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getUser = `-- name: GetUser :one
SELECT id, name, email, avatar_url, created_at, updated_at FROM "user"
WHERE id = $1
`
func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (User, error) {
row := q.db.QueryRow(ctx, getUser, id)
var i User
err := row.Scan(
&i.ID,
&i.Name,
&i.Email,
&i.AvatarUrl,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, name, email, avatar_url, created_at, updated_at FROM "user"
WHERE email = $1
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
row := q.db.QueryRow(ctx, getUserByEmail, email)
var i User
err := row.Scan(
&i.ID,
&i.Name,
&i.Email,
&i.AvatarUrl,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateUser = `-- name: UpdateUser :one
UPDATE "user" SET
name = COALESCE($2, name),
avatar_url = COALESCE($3, avatar_url),
updated_at = now()
WHERE id = $1
RETURNING id, name, email, avatar_url, created_at, updated_at
`
type UpdateUserParams struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
}
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error) {
row := q.db.QueryRow(ctx, updateUser, arg.ID, arg.Name, arg.AvatarUrl)
var i User
err := row.Scan(
&i.ID,
&i.Name,
&i.Email,
&i.AvatarUrl,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View file

@ -0,0 +1,160 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: workspace.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createWorkspace = `-- name: CreateWorkspace :one
INSERT INTO workspace (name, slug, description)
VALUES ($1, $2, $3)
RETURNING id, name, slug, description, settings, created_at, updated_at
`
type CreateWorkspaceParams struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description pgtype.Text `json:"description"`
}
func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) {
row := q.db.QueryRow(ctx, createWorkspace, arg.Name, arg.Slug, arg.Description)
var i Workspace
err := row.Scan(
&i.ID,
&i.Name,
&i.Slug,
&i.Description,
&i.Settings,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteWorkspace = `-- name: DeleteWorkspace :exec
DELETE FROM workspace WHERE id = $1
`
func (q *Queries) DeleteWorkspace(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteWorkspace, id)
return err
}
const getWorkspace = `-- name: GetWorkspace :one
SELECT id, name, slug, description, settings, created_at, updated_at FROM workspace
WHERE id = $1
`
func (q *Queries) GetWorkspace(ctx context.Context, id pgtype.UUID) (Workspace, error) {
row := q.db.QueryRow(ctx, getWorkspace, id)
var i Workspace
err := row.Scan(
&i.ID,
&i.Name,
&i.Slug,
&i.Description,
&i.Settings,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getWorkspaceBySlug = `-- name: GetWorkspaceBySlug :one
SELECT id, name, slug, description, settings, created_at, updated_at FROM workspace
WHERE slug = $1
`
func (q *Queries) GetWorkspaceBySlug(ctx context.Context, slug string) (Workspace, error) {
row := q.db.QueryRow(ctx, getWorkspaceBySlug, slug)
var i Workspace
err := row.Scan(
&i.ID,
&i.Name,
&i.Slug,
&i.Description,
&i.Settings,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listWorkspaces = `-- name: ListWorkspaces :many
SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at FROM workspace w
JOIN member m ON m.workspace_id = w.id
WHERE m.user_id = $1
ORDER BY w.created_at ASC
`
func (q *Queries) ListWorkspaces(ctx context.Context, userID pgtype.UUID) ([]Workspace, error) {
rows, err := q.db.Query(ctx, listWorkspaces, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Workspace{}
for rows.Next() {
var i Workspace
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Slug,
&i.Description,
&i.Settings,
&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 updateWorkspace = `-- name: UpdateWorkspace :one
UPDATE workspace SET
name = COALESCE($2, name),
description = COALESCE($3, description),
settings = COALESCE($4, settings),
updated_at = now()
WHERE id = $1
RETURNING id, name, slug, description, settings, created_at, updated_at
`
type UpdateWorkspaceParams struct {
ID pgtype.UUID `json:"id"`
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Settings []byte `json:"settings"`
}
func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) {
row := q.db.QueryRow(ctx, updateWorkspace,
arg.ID,
arg.Name,
arg.Description,
arg.Settings,
)
var i Workspace
err := row.Scan(
&i.ID,
&i.Name,
&i.Slug,
&i.Description,
&i.Settings,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View file

@ -0,0 +1,11 @@
-- name: ListActivities :many
SELECT * FROM activity_log
WHERE issue_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3;
-- name: CreateActivity :one
INSERT INTO activity_log (
workspace_id, issue_id, actor_type, actor_id, action, details
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;

View file

@ -0,0 +1,30 @@
-- name: ListAgents :many
SELECT * FROM agent
WHERE workspace_id = $1
ORDER BY created_at ASC;
-- name: GetAgent :one
SELECT * FROM agent
WHERE id = $1;
-- name: CreateAgent :one
INSERT INTO agent (
workspace_id, name, avatar_url, runtime_mode,
runtime_config, visibility, max_concurrent_tasks, owner_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: UpdateAgent :one
UPDATE agent SET
name = COALESCE(sqlc.narg('name'), name),
avatar_url = COALESCE(sqlc.narg('avatar_url'), avatar_url),
runtime_config = COALESCE(sqlc.narg('runtime_config'), runtime_config),
visibility = COALESCE(sqlc.narg('visibility'), visibility),
status = COALESCE(sqlc.narg('status'), status),
max_concurrent_tasks = COALESCE(sqlc.narg('max_concurrent_tasks'), max_concurrent_tasks),
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: DeleteAgent :exec
DELETE FROM agent WHERE id = $1;

View file

@ -0,0 +1,23 @@
-- name: ListComments :many
SELECT * FROM comment
WHERE issue_id = $1
ORDER BY created_at ASC;
-- name: GetComment :one
SELECT * FROM comment
WHERE id = $1;
-- name: CreateComment :one
INSERT INTO comment (issue_id, author_type, author_id, content, type)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: UpdateComment :one
UPDATE comment SET
content = $2,
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: DeleteComment :exec
DELETE FROM comment WHERE id = $1;

View file

@ -0,0 +1,30 @@
-- name: ListInboxItems :many
SELECT * FROM inbox_item
WHERE recipient_type = $1 AND recipient_id = $2 AND archived = false
ORDER BY created_at DESC
LIMIT $3 OFFSET $4;
-- name: GetInboxItem :one
SELECT * FROM inbox_item
WHERE id = $1;
-- name: CreateInboxItem :one
INSERT INTO inbox_item (
workspace_id, recipient_type, recipient_id,
type, severity, issue_id, title, body
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: MarkInboxRead :one
UPDATE inbox_item SET read = true
WHERE id = $1
RETURNING *;
-- name: ArchiveInboxItem :one
UPDATE inbox_item SET archived = true
WHERE id = $1
RETURNING *;
-- name: CountUnreadInbox :one
SELECT count(*) FROM inbox_item
WHERE recipient_type = $1 AND recipient_id = $2 AND read = false AND archived = false;

View file

@ -20,13 +20,13 @@ INSERT INTO issue (
-- name: UpdateIssue :one
UPDATE issue SET
title = COALESCE($2, title),
description = COALESCE($3, description),
status = COALESCE($4, status),
priority = COALESCE($5, priority),
assignee_type = $6,
assignee_id = $7,
position = COALESCE($8, position),
title = COALESCE(sqlc.narg('title'), title),
description = COALESCE(sqlc.narg('description'), description),
status = COALESCE(sqlc.narg('status'), status),
priority = COALESCE(sqlc.narg('priority'), priority),
assignee_type = sqlc.narg('assignee_type'),
assignee_id = sqlc.narg('assignee_id'),
position = COALESCE(sqlc.narg('position'), position),
updated_at = now()
WHERE id = $1
RETURNING *;

View file

@ -0,0 +1,33 @@
-- name: ListMembers :many
SELECT * FROM member
WHERE workspace_id = $1
ORDER BY created_at ASC;
-- name: GetMember :one
SELECT * FROM member
WHERE id = $1;
-- name: GetMemberByUserAndWorkspace :one
SELECT * FROM member
WHERE user_id = $1 AND workspace_id = $2;
-- name: CreateMember :one
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, $3)
RETURNING *;
-- name: UpdateMemberRole :one
UPDATE member SET role = $2
WHERE id = $1
RETURNING *;
-- name: DeleteMember :exec
DELETE FROM member WHERE id = $1;
-- name: ListMembersWithUser :many
SELECT m.id, m.workspace_id, m.user_id, m.role, m.created_at,
u.name as user_name, u.email as user_email, u.avatar_url as user_avatar_url
FROM member m
JOIN "user" u ON u.id = m.user_id
WHERE m.workspace_id = $1
ORDER BY m.created_at ASC;

View file

@ -0,0 +1,20 @@
-- name: GetUser :one
SELECT * FROM "user"
WHERE id = $1;
-- name: GetUserByEmail :one
SELECT * FROM "user"
WHERE email = $1;
-- name: CreateUser :one
INSERT INTO "user" (name, email, avatar_url)
VALUES ($1, $2, $3)
RETURNING *;
-- name: UpdateUser :one
UPDATE "user" SET
name = COALESCE($2, name),
avatar_url = COALESCE($3, avatar_url),
updated_at = now()
WHERE id = $1
RETURNING *;

View file

@ -0,0 +1,30 @@
-- name: ListWorkspaces :many
SELECT w.* FROM workspace w
JOIN member m ON m.workspace_id = w.id
WHERE m.user_id = $1
ORDER BY w.created_at ASC;
-- name: GetWorkspace :one
SELECT * FROM workspace
WHERE id = $1;
-- name: GetWorkspaceBySlug :one
SELECT * FROM workspace
WHERE slug = $1;
-- name: CreateWorkspace :one
INSERT INTO workspace (name, slug, description)
VALUES ($1, $2, $3)
RETURNING *;
-- name: UpdateWorkspace :one
UPDATE workspace SET
name = COALESCE(sqlc.narg('name'), name),
description = COALESCE(sqlc.narg('description'), description),
settings = COALESCE(sqlc.narg('settings'), settings),
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: DeleteWorkspace :exec
DELETE FROM workspace WHERE id = $1;