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:
commit
317e87fb97
73 changed files with 8264 additions and 766 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -21,6 +21,11 @@ coverage
|
|||
# Go
|
||||
server/bin/
|
||||
server/tmp/
|
||||
server/migrate
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
apps/web/test-results/
|
||||
|
||||
# context (agent workspace)
|
||||
.context
|
||||
|
|
|
|||
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
43
Makefile
43
Makefile
|
|
@ -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:
|
||||
|
|
|
|||
116
apps/web/app/(auth)/login/page.test.tsx
Normal file
116
apps/web/app/(auth)/login/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
246
apps/web/app/(dashboard)/issues/[id]/page.test.tsx
Normal file
246
apps/web/app/(dashboard)/issues/[id]/page.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ----
|
||||
|
|
|
|||
302
apps/web/app/(dashboard)/issues/page.test.tsx
Normal file
302
apps/web/app/(dashboard)/issues/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
17
apps/web/lib/api.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
281
apps/web/lib/auth-context.test.tsx
Normal file
281
apps/web/lib/auth-context.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
194
apps/web/lib/auth-context.tsx
Normal file
194
apps/web/lib/auth-context.tsx
Normal 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;
|
||||
}
|
||||
75
apps/web/lib/ws-context.tsx
Normal file
75
apps/web/lib/ws-context.tsx
Normal 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]);
|
||||
}
|
||||
|
|
@ -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
93
apps/web/test/helpers.tsx
Normal 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
33
apps/web/test/setup.ts
Normal 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
19
apps/web/vitest.config.ts
Normal 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
46
e2e/auth.spec.ts
Normal 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
47
e2e/comments.spec.ts
Normal 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
22
e2e/helpers.ts
Normal 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
77
e2e/issues.spec.ts
Normal 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
43
e2e/navigation.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "catalog:",
|
||||
"turbo": "^2.5.0",
|
||||
"typescript": "catalog:"
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
19
playwright.config.ts
Normal 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
1058
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
131
server/cmd/migrate/main.go
Normal file
131
server/cmd/migrate/main.go
Normal 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
162
server/cmd/seed/main.go
Normal 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)
|
||||
}
|
||||
618
server/cmd/server/integration_test.go
Normal file
618
server/cmd/server/integration_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + `"}`))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
98
server/cmd/server/router.go
Normal file
98
server/cmd/server/router.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
194
server/internal/handler/agent.go
Normal file
194
server/internal/handler/agent.go
Normal 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)
|
||||
}
|
||||
109
server/internal/handler/auth.go
Normal file
109
server/internal/handler/auth.go
Normal 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))
|
||||
}
|
||||
91
server/internal/handler/comment.go
Normal file
91
server/internal/handler/comment.go
Normal 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))
|
||||
}
|
||||
114
server/internal/handler/handler.go
Normal file
114
server/internal/handler/handler.go
Normal 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)
|
||||
}
|
||||
310
server/internal/handler/handler_test.go
Normal file
310
server/internal/handler/handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
100
server/internal/handler/inbox.go
Normal file
100
server/internal/handler/inbox.go
Normal 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))
|
||||
}
|
||||
341
server/internal/handler/issue.go
Normal file
341
server/internal/handler/issue.go
Normal 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)
|
||||
}
|
||||
226
server/internal/handler/workspace.go
Normal file
226
server/internal/handler/workspace.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
185
server/internal/middleware/auth_test.go
Normal file
185
server/internal/middleware/auth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
177
server/internal/realtime/hub_test.go
Normal file
177
server/internal/realtime/hub_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
93
server/pkg/db/generated/activity.sql.go
Normal file
93
server/pkg/db/generated/activity.sql.go
Normal 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
|
||||
}
|
||||
184
server/pkg/db/generated/agent.sql.go
Normal file
184
server/pkg/db/generated/agent.sql.go
Normal 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
|
||||
}
|
||||
142
server/pkg/db/generated/comment.sql.go
Normal file
142
server/pkg/db/generated/comment.sql.go
Normal 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
|
||||
}
|
||||
32
server/pkg/db/generated/db.go
Normal file
32
server/pkg/db/generated/db.go
Normal 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,
|
||||
}
|
||||
}
|
||||
206
server/pkg/db/generated/inbox.sql.go
Normal file
206
server/pkg/db/generated/inbox.sql.go
Normal 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
|
||||
}
|
||||
233
server/pkg/db/generated/issue.sql.go
Normal file
233
server/pkg/db/generated/issue.sql.go
Normal 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
|
||||
}
|
||||
192
server/pkg/db/generated/member.sql.go
Normal file
192
server/pkg/db/generated/member.sql.go
Normal 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
|
||||
}
|
||||
153
server/pkg/db/generated/models.go
Normal file
153
server/pkg/db/generated/models.go
Normal 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"`
|
||||
}
|
||||
105
server/pkg/db/generated/user.sql.go
Normal file
105
server/pkg/db/generated/user.sql.go
Normal 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
|
||||
}
|
||||
160
server/pkg/db/generated/workspace.sql.go
Normal file
160
server/pkg/db/generated/workspace.sql.go
Normal 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
|
||||
}
|
||||
11
server/pkg/db/queries/activity.sql
Normal file
11
server/pkg/db/queries/activity.sql
Normal 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 *;
|
||||
30
server/pkg/db/queries/agent.sql
Normal file
30
server/pkg/db/queries/agent.sql
Normal 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;
|
||||
23
server/pkg/db/queries/comment.sql
Normal file
23
server/pkg/db/queries/comment.sql
Normal 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;
|
||||
30
server/pkg/db/queries/inbox.sql
Normal file
30
server/pkg/db/queries/inbox.sql
Normal 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;
|
||||
|
|
@ -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 *;
|
||||
|
|
|
|||
33
server/pkg/db/queries/member.sql
Normal file
33
server/pkg/db/queries/member.sql
Normal 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;
|
||||
20
server/pkg/db/queries/user.sql
Normal file
20
server/pkg/db/queries/user.sql
Normal 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 *;
|
||||
30
server/pkg/db/queries/workspace.sql
Normal file
30
server/pkg/db/queries/workspace.sql
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue