diff --git a/.env.example b/.env.example index 14ed6161..1d54a44d 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,15 @@ # Database +COMPOSE_PROJECT_NAME=super_multica +POSTGRES_DB=multica +POSTGRES_USER=multica +POSTGRES_PASSWORD=multica +POSTGRES_PORT=5432 DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable # Server PORT=8080 JWT_SECRET=change-me-in-production +MULTICA_SERVER_URL=ws://localhost:8080/ws # Google OAuth GOOGLE_CLIENT_ID= @@ -11,5 +17,7 @@ GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback # Frontend +FRONTEND_PORT=3000 +FRONTEND_ORIGIN=http://localhost:3000 NEXT_PUBLIC_API_URL=http://localhost:8080 NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws diff --git a/Makefile b/Makefile index 3736cd27..8f36b591 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,45 @@ -.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop check +.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree + +MAIN_ENV_FILE ?= .env +WORKTREE_ENV_FILE ?= .env.worktree +ENV_FILE ?= $(if $(wildcard $(MAIN_ENV_FILE)),$(MAIN_ENV_FILE),$(if $(wildcard $(WORKTREE_ENV_FILE)),$(WORKTREE_ENV_FILE),$(MAIN_ENV_FILE))) + +ifneq ($(wildcard $(ENV_FILE)),) +include $(ENV_FILE) +endif + +POSTGRES_DB ?= multica +POSTGRES_USER ?= multica +POSTGRES_PASSWORD ?= multica +POSTGRES_PORT ?= 5432 +PORT ?= 8080 +FRONTEND_PORT ?= 3000 +FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT) +DATABASE_URL ?= postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@localhost:$(POSTGRES_PORT)/$(POSTGRES_DB)?sslmode=disable +NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT) +NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws +GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback +MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws +COMPOSE_PROJECT_NAME ?= super_multica + +export + +COMPOSE := docker compose --env-file $(ENV_FILE) # ---------- One-click commands ---------- # First-time setup: install deps, start DB, run migrations, seed data setup: + @echo "==> Using env file: $(ENV_FILE)" @echo "==> Installing dependencies..." pnpm install @echo "==> Starting PostgreSQL..." - @if pg_isready -h localhost -p 5432 -U multica > /dev/null 2>&1; then \ + @if pg_isready -h localhost -p $(POSTGRES_PORT) -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; then \ echo " PostgreSQL already running, skipping docker compose up."; \ else \ - docker compose up -d; \ + $(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 \ + until $(COMPOSE) exec -T postgres pg_isready -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; do \ sleep 1; \ done; \ fi @@ -25,11 +52,14 @@ setup: # Start all services (backend + frontend) start: - @if pg_isready -h localhost -p 5432 -U multica > /dev/null 2>&1; then \ + @echo "Using env file: $(ENV_FILE)" + @echo "Backend: http://localhost:$(PORT)" + @echo "Frontend: http://localhost:$(FRONTEND_PORT)" + @if pg_isready -h localhost -p $(POSTGRES_PORT) -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; then \ echo "PostgreSQL already running, skipping docker compose up."; \ else \ - docker compose up -d; \ - until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \ + $(COMPOSE) up -d; \ + until $(COMPOSE) exec -T postgres pg_isready -U $(POSTGRES_USER) -d $(POSTGRES_DB) > /dev/null 2>&1; do \ sleep 1; \ done; \ fi @@ -42,15 +72,42 @@ start: # 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 + @-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null + @-lsof -ti:$(FRONTEND_PORT) | xargs kill -9 2>/dev/null + $(COMPOSE) down @echo "✓ All services stopped." # Full verification: typecheck + unit tests + Go tests + E2E check: @bash scripts/check.sh +worktree-env: + @bash scripts/init-worktree-env.sh .env.worktree + +setup-main: + @$(MAKE) setup ENV_FILE=$(MAIN_ENV_FILE) + +start-main: + @$(MAKE) start ENV_FILE=$(MAIN_ENV_FILE) + +stop-main: + @$(MAKE) stop ENV_FILE=$(MAIN_ENV_FILE) + +check-main: + @ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh + +setup-worktree: + @$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE) + +start-worktree: + @$(MAKE) start ENV_FILE=$(WORKTREE_ENV_FILE) + +stop-worktree: + @$(MAKE) stop ENV_FILE=$(WORKTREE_ENV_FILE) + +check-worktree: + @ENV_FILE=$(WORKTREE_ENV_FILE) bash scripts/check.sh + # ---------- Individual commands ---------- # Go server diff --git a/README.md b/README.md index 00fad72e..968cd110 100644 --- a/README.md +++ b/README.md @@ -15,23 +15,27 @@ AI-native task management platform — like Linear, but with AI agents as first- # 1. Install dependencies pnpm install -# 2. Copy environment variables +# 2. Copy environment variables for the shared main environment cp .env.example .env -# 3. Start PostgreSQL -docker compose up -d +# 3. One-time setup: start DB, run migrations, seed data +make setup -# 4. Run database migrations -make migrate-up - -# 5. Start the Go backend (port 8080) -make dev - -# 6. In another terminal, start the frontend (port 3000) -pnpm dev:web +# 4. Start backend + frontend +make start ``` -Open [http://localhost:3000](http://localhost:3000) in your browser. +Open your configured `FRONTEND_ORIGIN` in the browser. By default that is [http://localhost:3000](http://localhost:3000). + +Default behavior now prefers the shared main environment in `.env`. If you want an isolated environment for a Git worktree, generate `.env.worktree` and use the explicit worktree targets: + +```bash +make worktree-env +make setup-worktree +make start-worktree +``` + +This lets you keep `.env` connected to your main database while using `.env.worktree` only for isolated feature testing. ## Project Structure @@ -61,7 +65,7 @@ Open [http://localhost:3000](http://localhost:3000) in your browser. | Command | Description | |---------|-------------| -| `pnpm dev:web` | Start Next.js dev server (port 3000) | +| `pnpm dev:web` | Start Next.js dev server (uses `FRONTEND_PORT`, default `3000`) | | `pnpm build` | Build all TypeScript packages | | `pnpm typecheck` | Run TypeScript type checking | | `pnpm test` | Run TypeScript tests | @@ -70,7 +74,7 @@ Open [http://localhost:3000](http://localhost:3000) in your browser. | Command | Description | |---------|-------------| -| `make dev` | Run Go server (port 8080) | +| `make dev` | Run Go server (uses `PORT`, default `8080`) | | `make daemon` | Run local agent daemon | | `make test` | Run Go tests | | `make build` | Build server & daemon binaries | @@ -85,13 +89,19 @@ Open [http://localhost:3000](http://localhost:3000) in your browser. | `make migrate-up` | Run database migrations | | `make migrate-down` | Rollback database migrations | | `make seed` | Seed test data | +| `make worktree-env` | Generate an isolated `.env.worktree` for the current worktree | +| `make setup-main` / `make start-main` | Force use of the shared main `.env` | +| `make setup-worktree` / `make start-worktree` | Force use of isolated `.env.worktree` | ## Environment Variables See [`.env.example`](.env.example) for all available variables: - `DATABASE_URL` — PostgreSQL connection string +- `COMPOSE_PROJECT_NAME` — Docker Compose project name +- `POSTGRES_DB` / `POSTGRES_PORT` — Per-worktree PostgreSQL database and host port - `PORT` — Backend server port (default: 8080) +- `FRONTEND_PORT` / `FRONTEND_ORIGIN` — Frontend port and browser origin - `JWT_SECRET` — JWT signing secret - `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` — Google OAuth (optional) - `NEXT_PUBLIC_API_URL` — Frontend → backend API URL diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index e902b5e3..29cafbd7 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -38,6 +38,12 @@ export default function DashboardLayout({ const [newSlug, setNewSlug] = useState(""); const [creating, setCreating] = useState(false); + useEffect(() => { + if (!isLoading && user && workspaces.length === 0) { + setShowCreateDialog(true); + } + }, [isLoading, user, workspaces.length]); + const handleNameChange = (value: string) => { setNewName(value); setNewSlug(value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")); @@ -75,6 +81,100 @@ export default function DashboardLayout({ if (!user) return null; + if (!workspace) { + return ( + <> +
+
+
+
+ +
+
+

Create your first workspace

+

{user.email}

+
+
+ +

+ You need a workspace before you can manage issues, agents, and inbox items. +

+ +
+ + +
+
+
+ + {showCreateDialog && ( + <> +
setShowCreateDialog(false)} + /> +
+
+

Create workspace

+

+ Create a new workspace for your team. +

+
+
+
+ + handleNameChange(e.target.value)} + placeholder="My Workspace" + 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" + /> +
+
+ + setNewSlug(e.target.value)} + placeholder="my-workspace" + 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" + /> +
+
+
+ + +
+
+ + )} + + ); + } + return (
{/* Sidebar */} diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index 2e1f8992..43621ccb 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; -import { Settings, Users, Building2, Save, Crown, Shield, User } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Settings, Users, Building2, Save, Crown, Shield, User, Plus, Trash2, LogOut } from "lucide-react"; import type { MemberWithUser, MemberRole } from "@multica/types"; import { useAuth } from "../../../lib/auth-context"; import { api } from "../../../lib/api"; @@ -12,9 +12,27 @@ const roleConfig: Record = { member: { label: "Member", icon: User }, }; -function MemberRow({ member }: { member: MemberWithUser }) { +function MemberRow({ + member, + canManage, + canManageOwners, + isSelf, + busy, + onRoleChange, + onRemove, +}: { + member: MemberWithUser; + canManage: boolean; + canManageOwners: boolean; + isSelf: boolean; + busy: boolean; + onRoleChange: (role: MemberRole) => void; + onRemove: () => void; +}) { const rc = roleConfig[member.role]; const RoleIcon = rc.icon; + const canEditRole = canManage && (!isSelf || canManageOwners) && (member.role !== "owner" || canManageOwners); + const canRemove = canManage && !isSelf && (member.role !== "owner" || canManageOwners); return (
@@ -30,27 +48,84 @@ function MemberRow({ member }: { member: MemberWithUser }) {
{member.name}
{member.email}
-
- - {rc.label} -
+ {canEditRole ? ( + + ) : ( +
+ + {rc.label} +
+ )} + {canRemove && ( + + )}
); } export default function SettingsPage() { - const { workspace, members, updateWorkspace } = useAuth(); + const { + user, + workspace, + members, + updateWorkspace, + updateCurrentUser, + refreshMembers, + leaveWorkspace, + deleteWorkspace, + } = useAuth(); const [name, setName] = useState(workspace?.name ?? ""); const [description, setDescription] = useState( workspace?.description ?? "", ); + const [profileName, setProfileName] = useState(user?.name ?? ""); + const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? ""); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); + const [profileSaving, setProfileSaving] = useState(false); + const [profileSaved, setProfileSaved] = useState(false); + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState("member"); + const [inviteLoading, setInviteLoading] = useState(false); + const [memberActionId, setMemberActionId] = useState(null); + const [workspaceError, setWorkspaceError] = useState(""); + const [profileError, setProfileError] = useState(""); + const [memberError, setMemberError] = useState(""); + const currentMember = members.find((member) => member.user_id === user?.id) ?? null; + const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin"; + const isOwner = currentMember?.role === "owner"; + + useEffect(() => { + setName(workspace?.name ?? ""); + setDescription(workspace?.description ?? ""); + }, [workspace]); + + useEffect(() => { + setProfileName(user?.name ?? ""); + setAvatarUrl(user?.avatar_url ?? ""); + }, [user]); const handleSave = async () => { if (!workspace) return; setSaving(true); + setWorkspaceError(""); try { const updated = await api.updateWorkspace(workspace.id, { name, @@ -60,12 +135,109 @@ export default function SettingsPage() { setSaved(true); setTimeout(() => setSaved(false), 2000); } catch (e) { - console.error("Failed to update workspace", e); + setWorkspaceError(e instanceof Error ? e.message : "Failed to update workspace"); } finally { setSaving(false); } }; + const handleProfileSave = async () => { + setProfileSaving(true); + setProfileError(""); + try { + const updated = await api.updateMe({ + name: profileName, + avatar_url: avatarUrl || undefined, + }); + updateCurrentUser(updated); + setProfileSaved(true); + setTimeout(() => setProfileSaved(false), 2000); + } catch (e) { + setProfileError(e instanceof Error ? e.message : "Failed to update profile"); + } finally { + setProfileSaving(false); + } + }; + + const handleAddMember = async () => { + if (!workspace) return; + setInviteLoading(true); + setMemberError(""); + try { + await api.createMember(workspace.id, { + email: inviteEmail, + role: inviteRole, + }); + setInviteEmail(""); + setInviteRole("member"); + await refreshMembers(); + } catch (e) { + setMemberError(e instanceof Error ? e.message : "Failed to add member"); + } finally { + setInviteLoading(false); + } + }; + + const handleRoleChange = async (memberId: string, role: MemberRole) => { + if (!workspace) return; + setMemberActionId(memberId); + setMemberError(""); + try { + await api.updateMember(workspace.id, memberId, { role }); + await refreshMembers(); + } catch (e) { + setMemberError(e instanceof Error ? e.message : "Failed to update member"); + } finally { + setMemberActionId(null); + } + }; + + const handleRemoveMember = async (member: MemberWithUser) => { + if (!workspace) return; + if (!window.confirm(`Remove ${member.name} from ${workspace.name}?`)) return; + + setMemberActionId(member.id); + setMemberError(""); + try { + await api.deleteMember(workspace.id, member.id); + await refreshMembers(); + } catch (e) { + setMemberError(e instanceof Error ? e.message : "Failed to remove member"); + } finally { + setMemberActionId(null); + } + }; + + const handleLeaveWorkspace = async () => { + if (!workspace) return; + if (!window.confirm(`Leave ${workspace.name}?`)) return; + + setMemberActionId("leave"); + setMemberError(""); + try { + await leaveWorkspace(workspace.id); + } catch (e) { + setMemberError(e instanceof Error ? e.message : "Failed to leave workspace"); + } finally { + setMemberActionId(null); + } + }; + + const handleDeleteWorkspace = async () => { + if (!workspace) return; + if (!window.confirm(`Delete ${workspace.name}? This cannot be undone.`)) return; + + setMemberActionId("delete-workspace"); + setMemberError(""); + try { + await deleteWorkspace(workspace.id); + } catch (e) { + setMemberError(e instanceof Error ? e.message : "Failed to delete workspace"); + } finally { + setMemberActionId(null); + } + }; + if (!workspace) return null; return ( @@ -76,6 +248,55 @@ export default function SettingsPage() {

Settings

+
+
+ +

Profile

+
+ +
+
+ + setProfileName(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" + /> +
+
+ + setAvatarUrl(e.target.value)} + placeholder="https://example.com/avatar.png" + 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" + /> +
+ {profileError && ( +

{profileError}

+ )} +
+ {profileSaved && ( + Saved! + )} + +
+
+
+ {/* Workspace info */}
@@ -92,6 +313,7 @@ export default function SettingsPage() { type="text" value={name} onChange={(e) => setName(e.target.value)} + disabled={!canManageWorkspace} 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" />
@@ -103,6 +325,7 @@ export default function SettingsPage() { value={description} onChange={(e) => setDescription(e.target.value)} rows={3} + disabled={!canManageWorkspace} 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?" /> @@ -116,18 +339,26 @@ export default function SettingsPage() {
+ {workspaceError && ( + {workspaceError} + )} {saved && ( Saved! )}
+ {!canManageWorkspace && ( +

+ Only admins and owners can update workspace settings. +

+ )}
@@ -142,15 +373,105 @@ export default function SettingsPage() { + {memberError && ( +

{memberError}

+ )} + + {canManageWorkspace && ( +
+
+ +

Add member

+
+
+ setInviteEmail(e.target.value)} + placeholder="user@company.com" + className="rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> + + +
+
+ )} +
{members.map((m) => ( - + handleRoleChange(m.id, role)} + onRemove={() => handleRemoveMember(m)} + /> ))} {members.length === 0 && (

No members found.

)}
+ +
+
+ +

Danger Zone

+
+ +
+
+
+

Leave workspace

+

+ Remove yourself from this workspace. +

+
+ +
+ + {isOwner && ( +
+
+

Delete workspace

+

+ Permanently delete this workspace and its data. +

+
+ +
+ )} +
+
); } diff --git a/apps/web/lib/auth-context.test.tsx b/apps/web/lib/auth-context.test.tsx index cc918a35..4431c0ff 100644 --- a/apps/web/lib/auth-context.test.tsx +++ b/apps/web/lib/auth-context.test.tsx @@ -4,8 +4,9 @@ import type { User, Workspace, MemberWithUser, Agent } from "@multica/types"; // Mock next/navigation const mockPush = vi.fn(); +const mockRefresh = vi.fn(); vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: mockPush }), + useRouter: () => ({ push: mockPush, refresh: mockRefresh }), })); // Must use vi.hoisted so the mock object is defined before vi.mock factory runs @@ -18,6 +19,9 @@ const mockApi = vi.hoisted(() => ({ listMembers: vi.fn(), listAgents: vi.fn(), createWorkspace: vi.fn(), + updateMe: vi.fn(), + leaveWorkspace: vi.fn(), + deleteWorkspace: vi.fn(), })); vi.mock("./api", () => ({ @@ -393,12 +397,6 @@ describe("AuthContext", () => { }); it("switchWorkspace updates context and calls setWorkspaceId", async () => { - const reloadMock = vi.fn(); - Object.defineProperty(window, "location", { - value: { ...window.location, reload: reloadMock }, - writable: true, - }); - const mockWorkspace2: Workspace = { id: "ws-2", name: "Second WS", @@ -434,6 +432,6 @@ describe("AuthContext", () => { expect(mockApi.setWorkspaceId).toHaveBeenCalledWith("ws-2"); expect(localStorage.getItem("multica_workspace_id")).toBe("ws-2"); - expect(reloadMock).toHaveBeenCalled(); + expect(mockRefresh).toHaveBeenCalled(); }); }); diff --git a/apps/web/lib/auth-context.tsx b/apps/web/lib/auth-context.tsx index 18333b6e..5b64a016 100644 --- a/apps/web/lib/auth-context.tsx +++ b/apps/web/lib/auth-context.tsx @@ -24,6 +24,10 @@ interface AuthContextValue { switchWorkspace: (workspaceId: string) => Promise; createWorkspace: (data: { name: string; slug: string; description?: string }) => Promise; updateWorkspace: (ws: Workspace) => void; + updateCurrentUser: (nextUser: User) => void; + leaveWorkspace: (workspaceId: string) => Promise; + deleteWorkspace: (workspaceId: string) => Promise; + refreshWorkspaces: () => Promise; refreshMembers: () => Promise; refreshAgents: () => Promise; getMemberName: (userId: string) => string; @@ -43,6 +47,44 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [isLoading, setIsLoading] = useState(true); const router = useRouter(); + const hydrateWorkspace = useCallback(async (wsList: Workspace[], preferredWorkspaceId?: string | null) => { + setWorkspaces(wsList); + + const nextWorkspace = + (preferredWorkspaceId ? wsList.find((item) => item.id === preferredWorkspaceId) : null) ?? + wsList[0] ?? + null; + + if (!nextWorkspace) { + api.setWorkspaceId(null); + localStorage.removeItem("multica_workspace_id"); + setWorkspace(null); + setMembers([]); + setAgents([]); + return null; + } + + api.setWorkspaceId(nextWorkspace.id); + localStorage.setItem("multica_workspace_id", nextWorkspace.id); + setWorkspace(nextWorkspace); + + const [nextMembers, nextAgents] = await Promise.all([ + api.listMembers(nextWorkspace.id), + api.listAgents({ workspace_id: nextWorkspace.id }), + ]); + setMembers(nextMembers); + setAgents(nextAgents); + + return nextWorkspace; + }, []); + + const refreshWorkspaces = useCallback(async () => { + const storedWorkspaceId = localStorage.getItem("multica_workspace_id"); + const wsList = await api.listWorkspaces(); + await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId); + return wsList; + }, [hydrateWorkspace, workspace]); + // Initialize from stored token useEffect(() => { const token = localStorage.getItem("multica_token"); @@ -53,7 +95,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } api.setToken(token); - if (wsId) api.setWorkspaceId(wsId); + api.setWorkspaceId(wsId); (async () => { try { @@ -61,30 +103,23 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(me); const wsList = await api.listWorkspaces(); - setWorkspaces(wsList); - if (wsList.length > 0) { - const stored = wsId ? wsList.find(w => w.id === wsId) : null; - const ws = stored ?? wsList[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); - } + await hydrateWorkspace(wsList, wsId); } catch { // Token invalid, clear it + api.setToken(null); + api.setWorkspaceId(null); localStorage.removeItem("multica_token"); localStorage.removeItem("multica_workspace_id"); + setUser(null); + setWorkspace(null); + setWorkspaces([]); + setMembers([]); + setAgents([]); } finally { setIsLoading(false); } })(); - }, []); + }, [hydrateWorkspace]); const login = useCallback(async (email: string, name?: string) => { const { token, user: u } = await api.login(email, name); @@ -92,27 +127,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { localStorage.setItem("multica_token", token); setUser(u); - // Load workspace const wsList = await api.listWorkspaces(); - setWorkspaces(wsList); - if (wsList.length > 0) { - const ws = wsList[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); - } + await hydrateWorkspace(wsList); router.push("/issues"); - }, [router]); + }, [hydrateWorkspace, router]); const logout = useCallback(() => { + api.setToken(null); + api.setWorkspaceId(null); localStorage.removeItem("multica_token"); localStorage.removeItem("multica_workspace_id"); setUser(null); @@ -124,33 +147,45 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, [router]); const switchWorkspace = useCallback(async (workspaceId: string) => { - const ws = workspaces.find(w => w.id === workspaceId); + const ws = workspaces.find((item) => item.id === workspaceId); if (!ws) return; - api.setWorkspaceId(ws.id); - localStorage.setItem("multica_workspace_id", ws.id); - setWorkspace(ws); - - const [m, a] = await Promise.all([ - api.listMembers(ws.id), - api.listAgents({ workspace_id: ws.id }), - ]); - setMembers(m); - setAgents(a); - - window.location.reload(); - }, [workspaces]); + await hydrateWorkspace(workspaces, ws.id); + router.refresh(); + }, [hydrateWorkspace, router, workspaces]); const createNewWorkspace = useCallback(async (data: { name: string; slug: string; description?: string }) => { const ws = await api.createWorkspace(data); - setWorkspaces(prev => [...prev, ws]); + setWorkspaces((prev) => [...prev, ws]); return ws; }, []); const updateWorkspaceState = useCallback((ws: Workspace) => { setWorkspace(ws); + setWorkspaces((prev) => prev.map((item) => (item.id === ws.id ? ws : item))); }, []); + const updateCurrentUser = useCallback((nextUser: User) => { + setUser(nextUser); + }, []); + + const reloadAfterWorkspaceRemoval = useCallback(async (removedWorkspaceId: string) => { + const wsList = await api.listWorkspaces(); + const preferredWorkspaceId = workspace?.id === removedWorkspaceId ? null : workspace?.id ?? null; + await hydrateWorkspace(wsList, preferredWorkspaceId); + router.refresh(); + }, [hydrateWorkspace, router, workspace]); + + const leaveWorkspace = useCallback(async (workspaceId: string) => { + await api.leaveWorkspace(workspaceId); + await reloadAfterWorkspaceRemoval(workspaceId); + }, [reloadAfterWorkspaceRemoval]); + + const deleteWorkspace = useCallback(async (workspaceId: string) => { + await api.deleteWorkspace(workspaceId); + await reloadAfterWorkspaceRemoval(workspaceId); + }, [reloadAfterWorkspaceRemoval]); + const refreshMembers = useCallback(async () => { if (!workspace) return; const m = await api.listMembers(workspace.id); @@ -215,6 +250,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { switchWorkspace, createWorkspace: createNewWorkspace, updateWorkspace: updateWorkspaceState, + updateCurrentUser, + leaveWorkspace, + deleteWorkspace, + refreshWorkspaces, refreshMembers, refreshAgents, getMemberName, diff --git a/apps/web/package.json b/apps/web/package.json index 3d5aa923..e64ff3bf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "next dev --port 3000", + "dev": "sh -c 'next dev --port \"${FRONTEND_PORT:-3000}\"'", "build": "next build", "start": "next start", "typecheck": "tsc --noEmit", diff --git a/apps/web/test/helpers.tsx b/apps/web/test/helpers.tsx index 717a1c51..ab472138 100644 --- a/apps/web/test/helpers.tsx +++ b/apps/web/test/helpers.tsx @@ -66,6 +66,14 @@ export const mockAuthValue: Record = { isLoading: false, login: vi.fn(), logout: vi.fn(), + workspaces: [mockWorkspace], + switchWorkspace: vi.fn(), + createWorkspace: vi.fn(), + updateWorkspace: vi.fn(), + updateCurrentUser: vi.fn(), + leaveWorkspace: vi.fn(), + deleteWorkspace: vi.fn(), + refreshWorkspaces: vi.fn(), refreshMembers: vi.fn(), refreshAgents: vi.fn(), getMemberName: (userId: string) => { diff --git a/docker-compose.yml b/docker-compose.yml index 8d424b6c..21b005f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,11 +2,11 @@ services: postgres: image: pgvector/pgvector:pg17 environment: - POSTGRES_DB: multica - POSTGRES_USER: multica - POSTGRES_PASSWORD: multica + POSTGRES_DB: ${POSTGRES_DB:-multica} + POSTGRES_USER: ${POSTGRES_USER:-multica} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica} ports: - - "5432:5432" + - "${POSTGRES_PORT:-5432}:5432" volumes: - pgdata:/var/lib/postgresql/data diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 02ba1beb..4c3b2a09 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -5,7 +5,7 @@ * have zero build-time coupling to monorepo packages. */ -const API_BASE = "http://localhost:8080"; +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`; export class TestApiClient { private token: string | null = null; diff --git a/packages/sdk/src/api-client.ts b/packages/sdk/src/api-client.ts index 2128c052..811b4946 100644 --- a/packages/sdk/src/api-client.ts +++ b/packages/sdk/src/api-client.ts @@ -3,6 +3,9 @@ import type { CreateIssueRequest, UpdateIssueRequest, ListIssuesResponse, + UpdateMeRequest, + CreateMemberRequest, + UpdateMemberRequest, Agent, InboxItem, Comment, @@ -25,11 +28,11 @@ export class ApiClient { this.baseUrl = baseUrl; } - setToken(token: string) { + setToken(token: string | null) { this.token = token; } - setWorkspaceId(id: string) { + setWorkspaceId(id: string | null) { this.workspaceId = id; } @@ -51,7 +54,16 @@ export class ApiClient { }); if (!res.ok) { - throw new Error(`API error: ${res.status} ${res.statusText}`); + let message = `API error: ${res.status} ${res.statusText}`; + try { + const data = await res.json() as { error?: string }; + if (typeof data.error === "string" && data.error) { + message = data.error; + } + } catch { + // Ignore non-JSON error bodies. + } + throw new Error(message); } // Handle 204 No Content @@ -74,6 +86,13 @@ export class ApiClient { return this.fetch("/api/me"); } + async updateMe(data: UpdateMeRequest): Promise { + return this.fetch("/api/me", { + method: "PATCH", + body: JSON.stringify(data), + }); + } + // Issues async listIssues(params?: { limit?: number; offset?: number; workspace_id?: string }): Promise { const search = new URLSearchParams(); @@ -163,7 +182,7 @@ export class ApiClient { async updateWorkspace(id: string, data: { name?: string; description?: string; settings?: Record }): Promise { return this.fetch(`/api/workspaces/${id}`, { - method: "PUT", + method: "PATCH", body: JSON.stringify(data), }); } @@ -172,4 +191,36 @@ export class ApiClient { async listMembers(workspaceId: string): Promise { return this.fetch(`/api/workspaces/${workspaceId}/members`); } + + async createMember(workspaceId: string, data: CreateMemberRequest): Promise { + return this.fetch(`/api/workspaces/${workspaceId}/members`, { + method: "POST", + body: JSON.stringify(data), + }); + } + + async updateMember(workspaceId: string, memberId: string, data: UpdateMemberRequest): Promise { + return this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, { + method: "PATCH", + body: JSON.stringify(data), + }); + } + + async deleteMember(workspaceId: string, memberId: string): Promise { + await this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, { + method: "DELETE", + }); + } + + async leaveWorkspace(workspaceId: string): Promise { + await this.fetch(`/api/workspaces/${workspaceId}/leave`, { + method: "POST", + }); + } + + async deleteWorkspace(workspaceId: string): Promise { + await this.fetch(`/api/workspaces/${workspaceId}`, { + method: "DELETE", + }); + } } diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 421b5e6e..b9d42769 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -1,4 +1,5 @@ import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue.js"; +import type { MemberRole } from "./workspace.js"; // Issue API export interface CreateIssueRequest { @@ -29,6 +30,20 @@ export interface ListIssuesResponse { total: number; } +export interface UpdateMeRequest { + name?: string; + avatar_url?: string; +} + +export interface CreateMemberRequest { + email: string; + role?: MemberRole; +} + +export interface UpdateMemberRequest { + role: MemberRole; +} + // Pagination export interface PaginationParams { limit?: number; diff --git a/playwright.config.ts b/playwright.config.ts index e3c16b5e..795e0825 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ timeout: 30000, retries: 0, use: { - baseURL: "http://localhost:3000", + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? process.env.FRONTEND_ORIGIN ?? "http://localhost:3000", headless: true, }, projects: [ diff --git a/scripts/check.sh b/scripts/check.sh index 1cddbea1..ef4af067 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -6,6 +6,24 @@ set -euo pipefail # Usage: bash scripts/check.sh # ========================================================================== +ENV_FILE="${ENV_FILE:-$(if [ -f .env ]; then echo .env; elif [ -f .env.worktree ]; then echo .env.worktree; else echo .env; fi)}" +if [ -f "$ENV_FILE" ]; then + set -a + # shellcheck disable=SC1090 + . "$ENV_FILE" + set +a +fi + +POSTGRES_DB="${POSTGRES_DB:-multica}" +POSTGRES_USER="${POSTGRES_USER:-multica}" +POSTGRES_PORT="${POSTGRES_PORT:-5432}" +PORT="${PORT:-8080}" +FRONTEND_PORT="${FRONTEND_PORT:-3000}" +PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL:-http://localhost:${FRONTEND_PORT}}" +export PLAYWRIGHT_BASE_URL + +COMPOSE_CMD=(docker compose --env-file "$ENV_FILE") + BACKEND_PID="" FRONTEND_PID="" STARTED_BACKEND=false @@ -57,13 +75,14 @@ wait_for_port() { # -------------------------------------------------------------------------- # Step 0: Ensure DB # -------------------------------------------------------------------------- +echo "==> Using env file: $ENV_FILE" echo "==> Checking PostgreSQL..." -if pg_isready -h localhost -p 5432 -U multica > /dev/null 2>&1; then +if pg_isready -h localhost -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then echo " Already running." else echo " Starting via docker compose..." - docker compose up -d - until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do + "${COMPOSE_CMD[@]}" up -d + until "${COMPOSE_CMD[@]}" exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; do sleep 1 done echo " PostgreSQL ready." @@ -96,24 +115,24 @@ echo "==> [3/5] Go tests..." echo "" echo "==> [4/5] Starting services for E2E..." -if curl -sf http://localhost:8080/health > /dev/null 2>&1; then - echo " Backend already running on :8080" +if curl -sf "http://localhost:${PORT}/health" > /dev/null 2>&1; then + echo " Backend already running on :$PORT" else echo " Starting backend..." (cd server && go run ./cmd/server) > /tmp/multica-check-backend.log 2>&1 & BACKEND_PID=$! STARTED_BACKEND=true - wait_for_port 8080 "Backend" 90 "/health" + wait_for_port "$PORT" "Backend" 90 "/health" fi -if curl -sf http://localhost:3000 > /dev/null 2>&1; then - echo " Frontend already running on :3000" +if curl -sf "http://localhost:${FRONTEND_PORT}" > /dev/null 2>&1; then + echo " Frontend already running on :$FRONTEND_PORT" else echo " Starting frontend..." pnpm dev:web > /tmp/multica-check-frontend.log 2>&1 & FRONTEND_PID=$! STARTED_FRONTEND=true - wait_for_port 3000 "Frontend" 120 "/" + wait_for_port "$FRONTEND_PORT" "Frontend" 120 "/" fi # -------------------------------------------------------------------------- diff --git a/scripts/init-worktree-env.sh b/scripts/init-worktree-env.sh new file mode 100644 index 00000000..cf123bef --- /dev/null +++ b/scripts/init-worktree-env.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:-.env.worktree}" + +if [ -f "$ENV_FILE" ] && [ "${FORCE:-0}" != "1" ]; then + echo "Refusing to overwrite existing $ENV_FILE. Re-run with FORCE=1 if you want to regenerate it." + exit 1 +fi + +worktree_name="${WORKTREE_NAME:-$(basename "$PWD")}" +slug="$(printf '%s' "$worktree_name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g; s/__*/_/g; s/^_//; s/_$//')" +if [ -z "$slug" ]; then + slug="multica" +fi + +hash_value="$(printf '%s' "$PWD" | cksum | awk '{print $1}')" +offset=$((hash_value % 1000)) + +postgres_db="multica_${slug}" +postgres_port=$((15432 + offset)) +backend_port=$((18080 + offset)) +frontend_port=$((13000 + offset)) +frontend_origin="http://localhost:${frontend_port}" +compose_project_name="multica_${slug}_${offset}" + +cat > "$ENV_FILE" <