feat(server): implement full REST API with JWT auth and real-time WebSocket

- Add HTTP handlers for issues, comments, agents, workspaces, inbox, members, and activity
- Implement JWT authentication middleware with Bearer token validation
- Add sqlc queries for all entities (CRUD operations)
- Extract router into reusable NewRouter() for testability
- Expand SDK with full API client methods (CRUD for all resources)
- Add updateWorkspace to SDK, add Member type to shared types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang 2026-03-22 11:50:03 +08:00
parent d75746021f
commit 1e61c1974c
35 changed files with 3478 additions and 104 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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