diff --git a/packages/sdk/src/api-client.ts b/packages/sdk/src/api-client.ts index 7eea3627..2128c052 100644 --- a/packages/sdk/src/api-client.ts +++ b/packages/sdk/src/api-client.ts @@ -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(path: string, init?: RequestInit): Promise { const headers: Record = { "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; } + // Auth + async login(email: string, name?: string): Promise { + return this.fetch("/auth/login", { + method: "POST", + body: JSON.stringify({ email, name }), + }); + } + + async getMe(): Promise { + return this.fetch("/api/me"); + } + // Issues - async listIssues(params?: { limit?: number; offset?: number }): Promise { + async listIssues(params?: { limit?: number; offset?: number; workspace_id?: string }): Promise { 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 { - 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 { + return this.fetch(`/api/issues/${issueId}/comments`); + } + + async createComment(issueId: string, content: string, type?: string): Promise { + return this.fetch(`/api/issues/${issueId}/comments`, { + method: "POST", + body: JSON.stringify({ content, type: type ?? "comment" }), + }); + } + // Agents - async listAgents(): Promise { - return this.fetch("/api/agents"); + async listAgents(params?: { workspace_id?: string }): Promise { + 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 { @@ -87,4 +140,36 @@ export class ApiClient { async markInboxRead(id: string): Promise { await this.fetch(`/api/inbox/${id}/read`, { method: "POST" }); } + + async archiveInbox(id: string): Promise { + await this.fetch(`/api/inbox/${id}/archive`, { method: "POST" }); + } + + // Workspaces + async listWorkspaces(): Promise { + return this.fetch("/api/workspaces"); + } + + async getWorkspace(id: string): Promise { + return this.fetch(`/api/workspaces/${id}`); + } + + async createWorkspace(data: { name: string; slug: string; description?: string }): Promise { + return this.fetch("/api/workspaces", { + method: "POST", + body: JSON.stringify(data), + }); + } + + async updateWorkspace(id: string, data: { name?: string; description?: string; settings?: Record }): Promise { + return this.fetch(`/api/workspaces/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); + } + + // Members + async listMembers(workspaceId: string): Promise { + return this.fetch(`/api/workspaces/${workspaceId}/members`); + } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index af8789c3..e5bba95e 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -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; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 4d8ceacc..28917ebd 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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"; diff --git a/packages/types/src/workspace.ts b/packages/types/src/workspace.ts index 6c503108..bf88543c 100644 --- a/packages/types/src/workspace.ts +++ b/packages/types/src/workspace.ts @@ -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; +} diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 80c3ca5c..04911282 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -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 + `"}`)) - } -} diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go new file mode 100644 index 00000000..b57c1d2f --- /dev/null +++ b/server/cmd/server/router.go @@ -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 +} diff --git a/server/go.mod b/server/go.mod index 59b659b5..f1f1f05f 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 +) diff --git a/server/go.sum b/server/go.sum index 07e986c3..a1fa3d9a 100644 --- a/server/go.sum +++ b/server/go.sum @@ -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= diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go new file mode 100644 index 00000000..426a5aef --- /dev/null +++ b/server/internal/handler/agent.go @@ -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) +} diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go new file mode 100644 index 00000000..5a28b8b3 --- /dev/null +++ b/server/internal/handler/auth.go @@ -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)) +} diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go new file mode 100644 index 00000000..09c12528 --- /dev/null +++ b/server/internal/handler/comment.go @@ -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)) +} diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go new file mode 100644 index 00000000..4a19f6fb --- /dev/null +++ b/server/internal/handler/handler.go @@ -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) +} diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go new file mode 100644 index 00000000..665015f6 --- /dev/null +++ b/server/internal/handler/handler_test.go @@ -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) + } +} diff --git a/server/internal/handler/inbox.go b/server/internal/handler/inbox.go new file mode 100644 index 00000000..2e6cf7da --- /dev/null +++ b/server/internal/handler/inbox.go @@ -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)) +} diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go new file mode 100644 index 00000000..f8755c08 --- /dev/null +++ b/server/internal/handler/issue.go @@ -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) +} diff --git a/server/internal/handler/workspace.go b/server/internal/handler/workspace.go new file mode 100644 index 00000000..80a12ea7 --- /dev/null +++ b/server/internal/handler/workspace.go @@ -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) +} diff --git a/server/internal/middleware/auth.go b/server/internal/middleware/auth.go index 6409489b..fa774d6c 100644 --- a/server/internal/middleware/auth.go +++ b/server/internal/middleware/auth.go @@ -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) }) } diff --git a/server/pkg/db/generated/activity.sql.go b/server/pkg/db/generated/activity.sql.go new file mode 100644 index 00000000..87fffefb --- /dev/null +++ b/server/pkg/db/generated/activity.sql.go @@ -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 +} diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go new file mode 100644 index 00000000..28bccc0c --- /dev/null +++ b/server/pkg/db/generated/agent.sql.go @@ -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 +} diff --git a/server/pkg/db/generated/comment.sql.go b/server/pkg/db/generated/comment.sql.go new file mode 100644 index 00000000..9675f23d --- /dev/null +++ b/server/pkg/db/generated/comment.sql.go @@ -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 +} diff --git a/server/pkg/db/generated/db.go b/server/pkg/db/generated/db.go new file mode 100644 index 00000000..9d485b5f --- /dev/null +++ b/server/pkg/db/generated/db.go @@ -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, + } +} diff --git a/server/pkg/db/generated/inbox.sql.go b/server/pkg/db/generated/inbox.sql.go new file mode 100644 index 00000000..df8a574c --- /dev/null +++ b/server/pkg/db/generated/inbox.sql.go @@ -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 +} diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go new file mode 100644 index 00000000..e1626d35 --- /dev/null +++ b/server/pkg/db/generated/issue.sql.go @@ -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 +} diff --git a/server/pkg/db/generated/member.sql.go b/server/pkg/db/generated/member.sql.go new file mode 100644 index 00000000..c305ea2d --- /dev/null +++ b/server/pkg/db/generated/member.sql.go @@ -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 +} diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go new file mode 100644 index 00000000..1c7aaad6 --- /dev/null +++ b/server/pkg/db/generated/models.go @@ -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"` +} diff --git a/server/pkg/db/generated/user.sql.go b/server/pkg/db/generated/user.sql.go new file mode 100644 index 00000000..ca0c5ad9 --- /dev/null +++ b/server/pkg/db/generated/user.sql.go @@ -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 +} diff --git a/server/pkg/db/generated/workspace.sql.go b/server/pkg/db/generated/workspace.sql.go new file mode 100644 index 00000000..6ef93238 --- /dev/null +++ b/server/pkg/db/generated/workspace.sql.go @@ -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 +} diff --git a/server/pkg/db/queries/activity.sql b/server/pkg/db/queries/activity.sql new file mode 100644 index 00000000..3e6dc39b --- /dev/null +++ b/server/pkg/db/queries/activity.sql @@ -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 *; diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql new file mode 100644 index 00000000..f75f30f5 --- /dev/null +++ b/server/pkg/db/queries/agent.sql @@ -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; diff --git a/server/pkg/db/queries/comment.sql b/server/pkg/db/queries/comment.sql new file mode 100644 index 00000000..7666de4f --- /dev/null +++ b/server/pkg/db/queries/comment.sql @@ -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; diff --git a/server/pkg/db/queries/inbox.sql b/server/pkg/db/queries/inbox.sql new file mode 100644 index 00000000..c6d817b1 --- /dev/null +++ b/server/pkg/db/queries/inbox.sql @@ -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; diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index a94c0e8e..39fc6248 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -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 *; diff --git a/server/pkg/db/queries/member.sql b/server/pkg/db/queries/member.sql new file mode 100644 index 00000000..f93365d3 --- /dev/null +++ b/server/pkg/db/queries/member.sql @@ -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; diff --git a/server/pkg/db/queries/user.sql b/server/pkg/db/queries/user.sql new file mode 100644 index 00000000..8909a388 --- /dev/null +++ b/server/pkg/db/queries/user.sql @@ -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 *; diff --git a/server/pkg/db/queries/workspace.sql b/server/pkg/db/queries/workspace.sql new file mode 100644 index 00000000..c85b30d2 --- /dev/null +++ b/server/pkg/db/queries/workspace.sql @@ -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;