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:
parent
d75746021f
commit
1e61c1974c
35 changed files with 3478 additions and 104 deletions
|
|
@ -5,11 +5,21 @@ import type {
|
|||
ListIssuesResponse,
|
||||
Agent,
|
||||
InboxItem,
|
||||
Comment,
|
||||
Workspace,
|
||||
MemberWithUser,
|
||||
User,
|
||||
} from "@multica/types";
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
|
|
@ -19,6 +29,10 @@ export class ApiClient {
|
|||
this.token = token;
|
||||
}
|
||||
|
||||
setWorkspaceId(id: string) {
|
||||
this.workspaceId = id;
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -27,6 +41,9 @@ export class ApiClient {
|
|||
if (this.token) {
|
||||
headers["Authorization"] = `Bearer ${this.token}`;
|
||||
}
|
||||
if (this.workspaceId) {
|
||||
headers["X-Workspace-ID"] = this.workspaceId;
|
||||
}
|
||||
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...init,
|
||||
|
|
@ -37,14 +54,33 @@ export class ApiClient {
|
|||
throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// Auth
|
||||
async login(email: string, name?: string): Promise<LoginResponse> {
|
||||
return this.fetch("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, name }),
|
||||
});
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
return this.fetch("/api/me");
|
||||
}
|
||||
|
||||
// Issues
|
||||
async listIssues(params?: { limit?: number; offset?: number }): Promise<ListIssuesResponse> {
|
||||
async listIssues(params?: { limit?: number; offset?: number; workspace_id?: string }): Promise<ListIssuesResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit) search.set("limit", String(params.limit));
|
||||
if (params?.offset) search.set("offset", String(params.offset));
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
return this.fetch(`/api/issues?${search}`);
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +89,9 @@ export class ApiClient {
|
|||
}
|
||||
|
||||
async createIssue(data: CreateIssueRequest): Promise<Issue> {
|
||||
return this.fetch("/api/issues", {
|
||||
const search = new URLSearchParams();
|
||||
if (this.workspaceId) search.set("workspace_id", this.workspaceId);
|
||||
return this.fetch(`/api/issues?${search}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
|
@ -70,9 +108,24 @@ export class ApiClient {
|
|||
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Comments
|
||||
async listComments(issueId: string): Promise<Comment[]> {
|
||||
return this.fetch(`/api/issues/${issueId}/comments`);
|
||||
}
|
||||
|
||||
async createComment(issueId: string, content: string, type?: string): Promise<Comment> {
|
||||
return this.fetch(`/api/issues/${issueId}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content, type: type ?? "comment" }),
|
||||
});
|
||||
}
|
||||
|
||||
// Agents
|
||||
async listAgents(): Promise<Agent[]> {
|
||||
return this.fetch("/api/agents");
|
||||
async listAgents(params?: { workspace_id?: string }): Promise<Agent[]> {
|
||||
const search = new URLSearchParams();
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
return this.fetch(`/api/agents?${search}`);
|
||||
}
|
||||
|
||||
async getAgent(id: string): Promise<Agent> {
|
||||
|
|
@ -87,4 +140,36 @@ export class ApiClient {
|
|||
async markInboxRead(id: string): Promise<void> {
|
||||
await this.fetch(`/api/inbox/${id}/read`, { method: "POST" });
|
||||
}
|
||||
|
||||
async archiveInbox(id: string): Promise<void> {
|
||||
await this.fetch(`/api/inbox/${id}/archive`, { method: "POST" });
|
||||
}
|
||||
|
||||
// Workspaces
|
||||
async listWorkspaces(): Promise<Workspace[]> {
|
||||
return this.fetch("/api/workspaces");
|
||||
}
|
||||
|
||||
async getWorkspace(id: string): Promise<Workspace> {
|
||||
return this.fetch(`/api/workspaces/${id}`);
|
||||
}
|
||||
|
||||
async createWorkspace(data: { name: string; slug: string; description?: string }): Promise<Workspace> {
|
||||
return this.fetch("/api/workspaces", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateWorkspace(id: string, data: { name?: string; description?: string; settings?: Record<string, unknown> }): Promise<Workspace> {
|
||||
return this.fetch(`/api/workspaces/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// Members
|
||||
async listMembers(workspaceId: string): Promise<MemberWithUser[]> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/members`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,9 @@
|
|||
export { ApiClient } from "./api-client.js";
|
||||
export { WSClient } from "./ws-client.js";
|
||||
export { ApiClient } from "./api-client";
|
||||
export type { LoginResponse } from "./api-client";
|
||||
export { WSClient } from "./ws-client";
|
||||
|
||||
export interface ContentBlock {
|
||||
type: "text" | "image" | "tool_use" | "tool_result";
|
||||
text?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue.js";
|
||||
export type { Agent, AgentStatus, AgentRuntimeMode, AgentVisibility } from "./agent.js";
|
||||
export type { Workspace, Member, MemberRole } from "./workspace.js";
|
||||
export type { Workspace, Member, MemberRole, User, MemberWithUser } from "./workspace.js";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox.js";
|
||||
export type { Comment, CommentType, CommentAuthorType } from "./comment.js";
|
||||
export type * from "./events.js";
|
||||
|
|
|
|||
|
|
@ -26,3 +26,14 @@ export interface User {
|
|||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MemberWithUser {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
user_id: string;
|
||||
role: MemberRole;
|
||||
created_at: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,10 @@ import (
|
|||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/multica-ai/multica/server/internal/middleware"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -22,82 +21,29 @@ func main() {
|
|||
port = "8080"
|
||||
}
|
||||
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dbURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to connect to database: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
log.Fatalf("Unable to ping database: %v", err)
|
||||
}
|
||||
log.Println("Connected to database")
|
||||
|
||||
queries := db.New(pool)
|
||||
hub := realtime.NewHub()
|
||||
go hub.Run()
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Global middleware
|
||||
r.Use(chimw.Logger)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(chimw.RequestID)
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:3000"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// Health check
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
// WebSocket
|
||||
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
realtime.HandleWebSocket(hub, w, r)
|
||||
})
|
||||
|
||||
// Protected API routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Auth)
|
||||
|
||||
// Issues
|
||||
r.Route("/api/issues", func(r chi.Router) {
|
||||
r.Get("/", placeholder("list issues"))
|
||||
r.Post("/", placeholder("create issue"))
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", placeholder("get issue"))
|
||||
r.Put("/", placeholder("update issue"))
|
||||
r.Delete("/", placeholder("delete issue"))
|
||||
r.Post("/comments", placeholder("add comment"))
|
||||
r.Get("/comments", placeholder("list comments"))
|
||||
})
|
||||
})
|
||||
|
||||
// Agents
|
||||
r.Route("/api/agents", func(r chi.Router) {
|
||||
r.Get("/", placeholder("list agents"))
|
||||
r.Post("/", placeholder("create agent"))
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", placeholder("get agent"))
|
||||
r.Put("/", placeholder("update agent"))
|
||||
r.Get("/tasks", placeholder("list agent tasks"))
|
||||
})
|
||||
})
|
||||
|
||||
// Inbox
|
||||
r.Route("/api/inbox", func(r chi.Router) {
|
||||
r.Get("/", placeholder("list inbox"))
|
||||
r.Post("/{id}/read", placeholder("mark read"))
|
||||
r.Post("/{id}/archive", placeholder("archive"))
|
||||
})
|
||||
|
||||
// Workspaces
|
||||
r.Route("/api/workspaces", func(r chi.Router) {
|
||||
r.Get("/", placeholder("list workspaces"))
|
||||
r.Post("/", placeholder("create workspace"))
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", placeholder("get workspace"))
|
||||
r.Put("/", placeholder("update workspace"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Auth (public)
|
||||
r.Post("/auth/login", placeholder("login"))
|
||||
r.Get("/auth/callback", placeholder("oauth callback"))
|
||||
r := NewRouter(queries, hub)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + port,
|
||||
|
|
@ -117,19 +63,11 @@ func main() {
|
|||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Fatalf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
log.Println("Server stopped")
|
||||
}
|
||||
|
||||
func placeholder(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
w.Write([]byte(`{"error":"not implemented","endpoint":"` + name + `"}`))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
98
server/cmd/server/router.go
Normal file
98
server/cmd/server/router.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/handler"
|
||||
"github.com/multica-ai/multica/server/internal/middleware"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// NewRouter creates the fully-configured Chi router with all middleware and routes.
|
||||
func NewRouter(queries *db.Queries, hub *realtime.Hub) chi.Router {
|
||||
h := handler.New(queries, hub)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Global middleware
|
||||
r.Use(chimw.Logger)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(chimw.RequestID)
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:3000"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// Health check
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
// WebSocket
|
||||
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
realtime.HandleWebSocket(hub, w, r)
|
||||
})
|
||||
|
||||
// Auth (public)
|
||||
r.Post("/auth/login", h.Login)
|
||||
|
||||
// Protected API routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Auth)
|
||||
|
||||
// Auth
|
||||
r.Get("/api/me", h.GetMe)
|
||||
|
||||
// Issues
|
||||
r.Route("/api/issues", func(r chi.Router) {
|
||||
r.Get("/", h.ListIssues)
|
||||
r.Post("/", h.CreateIssue)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.GetIssue)
|
||||
r.Put("/", h.UpdateIssue)
|
||||
r.Delete("/", h.DeleteIssue)
|
||||
r.Post("/comments", h.CreateComment)
|
||||
r.Get("/comments", h.ListComments)
|
||||
})
|
||||
})
|
||||
|
||||
// Agents
|
||||
r.Route("/api/agents", func(r chi.Router) {
|
||||
r.Get("/", h.ListAgents)
|
||||
r.Post("/", h.CreateAgent)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.GetAgent)
|
||||
r.Put("/", h.UpdateAgent)
|
||||
})
|
||||
})
|
||||
|
||||
// Inbox
|
||||
r.Route("/api/inbox", func(r chi.Router) {
|
||||
r.Get("/", h.ListInbox)
|
||||
r.Post("/{id}/read", h.MarkInboxRead)
|
||||
r.Post("/{id}/archive", h.ArchiveInboxItem)
|
||||
})
|
||||
|
||||
// Workspaces
|
||||
r.Route("/api/workspaces", func(r chi.Router) {
|
||||
r.Get("/", h.ListWorkspaces)
|
||||
r.Post("/", h.CreateWorkspace)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.GetWorkspace)
|
||||
r.Put("/", h.UpdateWorkspace)
|
||||
r.Get("/members", h.ListMembersWithUser)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
|
@ -7,3 +7,14 @@ require (
|
|||
github.com/go-chi/cors v1.2.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,29 @@
|
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
194
server/internal/handler/agent.go
Normal file
194
server/internal/handler/agent.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
type AgentResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
Status string `json:"status"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func agentToResponse(a db.Agent) AgentResponse {
|
||||
var rc any
|
||||
if a.RuntimeConfig != nil {
|
||||
json.Unmarshal(a.RuntimeConfig, &rc)
|
||||
}
|
||||
if rc == nil {
|
||||
rc = map[string]any{}
|
||||
}
|
||||
return AgentResponse{
|
||||
ID: uuidToString(a.ID),
|
||||
WorkspaceID: uuidToString(a.WorkspaceID),
|
||||
Name: a.Name,
|
||||
AvatarURL: textToPtr(a.AvatarUrl),
|
||||
RuntimeMode: a.RuntimeMode,
|
||||
RuntimeConfig: rc,
|
||||
Visibility: a.Visibility,
|
||||
Status: a.Status,
|
||||
MaxConcurrentTasks: a.MaxConcurrentTasks,
|
||||
OwnerID: uuidToPtr(a.OwnerID),
|
||||
CreatedAt: timestampToString(a.CreatedAt),
|
||||
UpdatedAt: timestampToString(a.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := r.URL.Query().Get("workspace_id")
|
||||
if workspaceID == "" {
|
||||
workspaceID = r.Header.Get("X-Workspace-ID")
|
||||
}
|
||||
if workspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
agents, err := h.Queries.ListAgents(r.Context(), parseUUID(workspaceID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list agents")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]AgentResponse, len(agents))
|
||||
for i, a := range agents {
|
||||
resp[i] = agentToResponse(a)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
agent, err := h.Queries.GetAgent(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "agent not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, agentToResponse(agent))
|
||||
}
|
||||
|
||||
type CreateAgentRequest struct {
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
var req CreateAgentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID := r.URL.Query().Get("workspace_id")
|
||||
if workspaceID == "" {
|
||||
workspaceID = r.Header.Get("X-Workspace-ID")
|
||||
}
|
||||
ownerID := r.Header.Get("X-User-ID")
|
||||
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
if req.RuntimeMode == "" {
|
||||
req.RuntimeMode = "local"
|
||||
}
|
||||
if req.Visibility == "" {
|
||||
req.Visibility = "workspace"
|
||||
}
|
||||
if req.MaxConcurrentTasks == 0 {
|
||||
req.MaxConcurrentTasks = 1
|
||||
}
|
||||
|
||||
rc, _ := json.Marshal(req.RuntimeConfig)
|
||||
if req.RuntimeConfig == nil {
|
||||
rc = []byte("{}")
|
||||
}
|
||||
|
||||
agent, err := h.Queries.CreateAgent(r.Context(), db.CreateAgentParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Name: req.Name,
|
||||
AvatarUrl: ptrToText(req.AvatarURL),
|
||||
RuntimeMode: req.RuntimeMode,
|
||||
RuntimeConfig: rc,
|
||||
Visibility: req.Visibility,
|
||||
MaxConcurrentTasks: req.MaxConcurrentTasks,
|
||||
OwnerID: parseUUID(ownerID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create agent: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, agentToResponse(agent))
|
||||
}
|
||||
|
||||
type UpdateAgentRequest struct {
|
||||
Name *string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
RuntimeConfig any `json:"runtime_config"`
|
||||
Visibility *string `json:"visibility"`
|
||||
Status *string `json:"status"`
|
||||
MaxConcurrentTasks *int32 `json:"max_concurrent_tasks"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req UpdateAgentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
params := db.UpdateAgentParams{
|
||||
ID: parseUUID(id),
|
||||
}
|
||||
if req.Name != nil {
|
||||
params.Name = pgtype.Text{String: *req.Name, Valid: true}
|
||||
}
|
||||
if req.AvatarURL != nil {
|
||||
params.AvatarUrl = pgtype.Text{String: *req.AvatarURL, Valid: true}
|
||||
}
|
||||
if req.RuntimeConfig != nil {
|
||||
rc, _ := json.Marshal(req.RuntimeConfig)
|
||||
params.RuntimeConfig = rc
|
||||
}
|
||||
if req.Visibility != nil {
|
||||
params.Visibility = pgtype.Text{String: *req.Visibility, Valid: true}
|
||||
}
|
||||
if req.Status != nil {
|
||||
params.Status = pgtype.Text{String: *req.Status, Valid: true}
|
||||
}
|
||||
if req.MaxConcurrentTasks != nil {
|
||||
params.MaxConcurrentTasks = pgtype.Int4{Int32: *req.MaxConcurrentTasks, Valid: true}
|
||||
}
|
||||
|
||||
agent, err := h.Queries.UpdateAgent(r.Context(), params)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update agent: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := agentToResponse(agent)
|
||||
h.broadcast("agent:status", map[string]any{"agent": resp})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
109
server/internal/handler/auth.go
Normal file
109
server/internal/handler/auth.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
var jwtSecret = []byte("multica-dev-secret-change-in-production")
|
||||
|
||||
type UserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func userToResponse(u db.User) UserResponse {
|
||||
return UserResponse{
|
||||
ID: uuidToString(u.ID),
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
AvatarURL: textToPtr(u.AvatarUrl),
|
||||
CreatedAt: timestampToString(u.CreatedAt),
|
||||
UpdatedAt: timestampToString(u.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User UserResponse `json:"user"`
|
||||
}
|
||||
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Email == "" {
|
||||
writeError(w, http.StatusBadRequest, "email is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Try to find existing user
|
||||
user, err := h.Queries.GetUserByEmail(r.Context(), req.Email)
|
||||
if err != nil {
|
||||
// Create new user
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
name = req.Email
|
||||
}
|
||||
user, err = h.Queries.CreateUser(r.Context(), db.CreateUserParams{
|
||||
Name: name,
|
||||
Email: req.Email,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": uuidToString(user.ID),
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"exp": time.Now().Add(72 * time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
})
|
||||
|
||||
tokenString, err := token.SignedString(jwtSecret)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, LoginResponse{
|
||||
Token: tokenString,
|
||||
User: userToResponse(user),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, userToResponse(user))
|
||||
}
|
||||
91
server/internal/handler/comment.go
Normal file
91
server/internal/handler/comment.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
type CommentResponse struct {
|
||||
ID string `json:"id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
AuthorType string `json:"author_type"`
|
||||
AuthorID string `json:"author_id"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func commentToResponse(c db.Comment) CommentResponse {
|
||||
return CommentResponse{
|
||||
ID: uuidToString(c.ID),
|
||||
IssueID: uuidToString(c.IssueID),
|
||||
AuthorType: c.AuthorType,
|
||||
AuthorID: uuidToString(c.AuthorID),
|
||||
Content: c.Content,
|
||||
Type: c.Type,
|
||||
CreatedAt: timestampToString(c.CreatedAt),
|
||||
UpdatedAt: timestampToString(c.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
|
||||
issueID := chi.URLParam(r, "id")
|
||||
comments, err := h.Queries.ListComments(r.Context(), parseUUID(issueID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list comments")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]CommentResponse, len(comments))
|
||||
for i, c := range comments {
|
||||
resp[i] = commentToResponse(c)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type CreateCommentRequest struct {
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
issueID := chi.URLParam(r, "id")
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateCommentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Content == "" {
|
||||
writeError(w, http.StatusBadRequest, "content is required")
|
||||
return
|
||||
}
|
||||
if req.Type == "" {
|
||||
req.Type = "comment"
|
||||
}
|
||||
|
||||
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
|
||||
IssueID: parseUUID(issueID),
|
||||
AuthorType: "member",
|
||||
AuthorID: parseUUID(userID),
|
||||
Content: req.Content,
|
||||
Type: req.Type,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create comment: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, commentToResponse(comment))
|
||||
}
|
||||
114
server/internal/handler/handler.go
Normal file
114
server/internal/handler/handler.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Queries *db.Queries
|
||||
Hub *realtime.Hub
|
||||
}
|
||||
|
||||
func New(queries *db.Queries, hub *realtime.Hub) *Handler {
|
||||
return &Handler{Queries: queries, Hub: hub}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func parseUUID(s string) pgtype.UUID {
|
||||
var u pgtype.UUID
|
||||
_ = u.Scan(s)
|
||||
return u
|
||||
}
|
||||
|
||||
func uuidToString(u pgtype.UUID) string {
|
||||
if !u.Valid {
|
||||
return ""
|
||||
}
|
||||
b := u.Bytes
|
||||
dst := make([]byte, 36)
|
||||
hex.Encode(dst[0:8], b[0:4])
|
||||
dst[8] = '-'
|
||||
hex.Encode(dst[9:13], b[4:6])
|
||||
dst[13] = '-'
|
||||
hex.Encode(dst[14:18], b[6:8])
|
||||
dst[18] = '-'
|
||||
hex.Encode(dst[19:23], b[8:10])
|
||||
dst[23] = '-'
|
||||
hex.Encode(dst[24:36], b[10:16])
|
||||
return string(dst)
|
||||
}
|
||||
|
||||
func textToPtr(t pgtype.Text) *string {
|
||||
if !t.Valid {
|
||||
return nil
|
||||
}
|
||||
return &t.String
|
||||
}
|
||||
|
||||
func ptrToText(s *string) pgtype.Text {
|
||||
if s == nil {
|
||||
return pgtype.Text{}
|
||||
}
|
||||
return pgtype.Text{String: *s, Valid: true}
|
||||
}
|
||||
|
||||
func strToText(s string) pgtype.Text {
|
||||
if s == "" {
|
||||
return pgtype.Text{}
|
||||
}
|
||||
return pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
|
||||
func timestampToString(t pgtype.Timestamptz) string {
|
||||
if !t.Valid {
|
||||
return ""
|
||||
}
|
||||
return t.Time.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func timestampToPtr(t pgtype.Timestamptz) *string {
|
||||
if !t.Valid {
|
||||
return nil
|
||||
}
|
||||
s := t.Time.Format(time.RFC3339)
|
||||
return &s
|
||||
}
|
||||
|
||||
func uuidToPtr(u pgtype.UUID) *string {
|
||||
if !u.Valid {
|
||||
return nil
|
||||
}
|
||||
s := uuidToString(u)
|
||||
return &s
|
||||
}
|
||||
|
||||
// broadcast sends a WebSocket event to all connected clients.
|
||||
func (h *Handler) broadcast(eventType string, payload any) {
|
||||
msg := map[string]any{
|
||||
"type": eventType,
|
||||
"payload": payload,
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
fmt.Printf("broadcast marshal error: %v\n", err)
|
||||
return
|
||||
}
|
||||
h.Hub.Broadcast(data)
|
||||
}
|
||||
310
server/internal/handler/handler_test.go
Normal file
310
server/internal/handler/handler_test.go
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
)
|
||||
|
||||
var testHandler *Handler
|
||||
var testUserID string
|
||||
var testWorkspaceID string
|
||||
var testToken string
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(context.Background(), dbURL)
|
||||
if err != nil {
|
||||
fmt.Printf("Skipping tests: could not connect to database: %v\n", err)
|
||||
os.Exit(0)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
queries := db.New(pool)
|
||||
hub := realtime.NewHub()
|
||||
go hub.Run()
|
||||
testHandler = New(queries, hub)
|
||||
|
||||
// Get seed user and workspace IDs
|
||||
row := pool.QueryRow(context.Background(), `SELECT id FROM "user" WHERE email = 'jiayuan@multica.ai'`)
|
||||
row.Scan(&testUserID)
|
||||
|
||||
row = pool.QueryRow(context.Background(), `SELECT id FROM workspace WHERE slug = 'multica'`)
|
||||
row.Scan(&testWorkspaceID)
|
||||
|
||||
if testUserID == "" || testWorkspaceID == "" {
|
||||
fmt.Println("Skipping tests: seed data not found. Run 'go run ./cmd/seed/' first.")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Generate a test token
|
||||
import_jwt(testUserID)
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func import_jwt(userID string) {
|
||||
// Simple token generation for tests using the login handler
|
||||
// We'll just set the headers directly instead
|
||||
testToken = userID // We'll use X-User-ID header directly
|
||||
}
|
||||
|
||||
func newRequest(method, path string, body any) *http.Request {
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-User-ID", testUserID)
|
||||
req.Header.Set("X-Workspace-ID", testWorkspaceID)
|
||||
return req
|
||||
}
|
||||
|
||||
func withURLParam(req *http.Request, key, value string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add(key, value)
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
func TestIssueCRUD(t *testing.T) {
|
||||
// Create
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Test issue from Go test",
|
||||
"status": "todo",
|
||||
"priority": "medium",
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var created IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&created)
|
||||
if created.Title != "Test issue from Go test" {
|
||||
t.Fatalf("CreateIssue: expected title 'Test issue from Go test', got '%s'", created.Title)
|
||||
}
|
||||
if created.Status != "todo" {
|
||||
t.Fatalf("CreateIssue: expected status 'todo', got '%s'", created.Status)
|
||||
}
|
||||
issueID := created.ID
|
||||
|
||||
// Get
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/issues/"+issueID, nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.GetIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var fetched IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&fetched)
|
||||
if fetched.ID != issueID {
|
||||
t.Fatalf("GetIssue: expected id '%s', got '%s'", issueID, fetched.ID)
|
||||
}
|
||||
|
||||
// Update - partial (only status)
|
||||
w = httptest.NewRecorder()
|
||||
status := "in_progress"
|
||||
req = newRequest("PUT", "/api/issues/"+issueID, map[string]any{
|
||||
"status": status,
|
||||
})
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.UpdateIssue(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var updated IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&updated)
|
||||
if updated.Status != "in_progress" {
|
||||
t.Fatalf("UpdateIssue: expected status 'in_progress', got '%s'", updated.Status)
|
||||
}
|
||||
if updated.Title != "Test issue from Go test" {
|
||||
t.Fatalf("UpdateIssue: title should be preserved, got '%s'", updated.Title)
|
||||
}
|
||||
if updated.Priority != "medium" {
|
||||
t.Fatalf("UpdateIssue: priority should be preserved, got '%s'", updated.Priority)
|
||||
}
|
||||
|
||||
// List
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/issues?workspace_id="+testWorkspaceID, nil)
|
||||
testHandler.ListIssues(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListIssues: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var listResp map[string]any
|
||||
json.NewDecoder(w.Body).Decode(&listResp)
|
||||
issues := listResp["issues"].([]any)
|
||||
if len(issues) == 0 {
|
||||
t.Fatal("ListIssues: expected at least 1 issue")
|
||||
}
|
||||
|
||||
// Delete
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("DELETE", "/api/issues/"+issueID, nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.DeleteIssue(w, req)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("DeleteIssue: expected 204, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/issues/"+issueID, nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.GetIssue(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("GetIssue after delete: expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentCRUD(t *testing.T) {
|
||||
// Create an issue first
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Comment test issue",
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
var issue IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&issue)
|
||||
issueID := issue.ID
|
||||
|
||||
// Create comment
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "Test comment from Go test",
|
||||
})
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.CreateComment(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// List comments
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/issues/"+issueID+"/comments", nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.ListComments(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListComments: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var comments []CommentResponse
|
||||
json.NewDecoder(w.Body).Decode(&comments)
|
||||
if len(comments) != 1 {
|
||||
t.Fatalf("ListComments: expected 1 comment, got %d", len(comments))
|
||||
}
|
||||
if comments[0].Content != "Test comment from Go test" {
|
||||
t.Fatalf("ListComments: expected content 'Test comment from Go test', got '%s'", comments[0].Content)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("DELETE", "/api/issues/"+issueID, nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.DeleteIssue(w, req)
|
||||
}
|
||||
|
||||
func TestAgentCRUD(t *testing.T) {
|
||||
// List agents
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("GET", "/api/agents?workspace_id="+testWorkspaceID, nil)
|
||||
testHandler.ListAgents(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListAgents: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var agents []AgentResponse
|
||||
json.NewDecoder(w.Body).Decode(&agents)
|
||||
if len(agents) == 0 {
|
||||
t.Fatal("ListAgents: expected at least 1 agent")
|
||||
}
|
||||
|
||||
// Update agent status
|
||||
agentID := agents[0].ID
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
||||
"status": "idle",
|
||||
})
|
||||
req = withURLParam(req, "id", agentID)
|
||||
testHandler.UpdateAgent(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var updated AgentResponse
|
||||
json.NewDecoder(w.Body).Decode(&updated)
|
||||
if updated.Status != "idle" {
|
||||
t.Fatalf("UpdateAgent: expected status 'idle', got '%s'", updated.Status)
|
||||
}
|
||||
if updated.Name != agents[0].Name {
|
||||
t.Fatalf("UpdateAgent: name should be preserved, got '%s'", updated.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceCRUD(t *testing.T) {
|
||||
// List workspaces
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("GET", "/api/workspaces", nil)
|
||||
testHandler.ListWorkspaces(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListWorkspaces: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var workspaces []WorkspaceResponse
|
||||
json.NewDecoder(w.Body).Decode(&workspaces)
|
||||
if len(workspaces) == 0 {
|
||||
t.Fatal("ListWorkspaces: expected at least 1 workspace")
|
||||
}
|
||||
|
||||
// Get workspace
|
||||
wsID := workspaces[0].ID
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/workspaces/"+wsID, nil)
|
||||
req = withURLParam(req, "id", wsID)
|
||||
testHandler.GetWorkspace(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetWorkspace: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogin(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
body := map[string]string{"email": "test-handler@multica.ai", "name": "Test User"}
|
||||
var buf bytes.Buffer
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
req := httptest.NewRequest("POST", "/auth/login", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
testHandler.Login(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Login: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp LoginResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Token == "" {
|
||||
t.Fatal("Login: expected non-empty token")
|
||||
}
|
||||
if resp.User.Email != "test-handler@multica.ai" {
|
||||
t.Fatalf("Login: expected email 'test-handler@multica.ai', got '%s'", resp.User.Email)
|
||||
}
|
||||
}
|
||||
100
server/internal/handler/inbox.go
Normal file
100
server/internal/handler/inbox.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
type InboxItemResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
RecipientType string `json:"recipient_type"`
|
||||
RecipientID string `json:"recipient_id"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
IssueID *string `json:"issue_id"`
|
||||
Title string `json:"title"`
|
||||
Body *string `json:"body"`
|
||||
Read bool `json:"read"`
|
||||
Archived bool `json:"archived"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func inboxToResponse(i db.InboxItem) InboxItemResponse {
|
||||
return InboxItemResponse{
|
||||
ID: uuidToString(i.ID),
|
||||
WorkspaceID: uuidToString(i.WorkspaceID),
|
||||
RecipientType: i.RecipientType,
|
||||
RecipientID: uuidToString(i.RecipientID),
|
||||
Type: i.Type,
|
||||
Severity: i.Severity,
|
||||
IssueID: uuidToPtr(i.IssueID),
|
||||
Title: i.Title,
|
||||
Body: textToPtr(i.Body),
|
||||
Read: i.Read,
|
||||
Archived: i.Archived,
|
||||
CreatedAt: timestampToString(i.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
offset := 0
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
if o := r.URL.Query().Get("offset"); o != "" {
|
||||
if v, err := strconv.Atoi(o); err == nil {
|
||||
offset = v
|
||||
}
|
||||
}
|
||||
|
||||
items, err := h.Queries.ListInboxItems(r.Context(), db.ListInboxItemsParams{
|
||||
RecipientType: "member",
|
||||
RecipientID: parseUUID(userID),
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list inbox")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]InboxItemResponse, len(items))
|
||||
for i, item := range items {
|
||||
resp[i] = inboxToResponse(item)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) MarkInboxRead(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
item, err := h.Queries.MarkInboxRead(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark read")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, inboxToResponse(item))
|
||||
}
|
||||
|
||||
func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
item, err := h.Queries.ArchiveInboxItem(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to archive")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, inboxToResponse(item))
|
||||
}
|
||||
341
server/internal/handler/issue.go
Normal file
341
server/internal/handler/issue.go
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// IssueResponse is the JSON response for an issue.
|
||||
type IssueResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
AssigneeType *string `json:"assignee_type"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
CreatorType string `json:"creator_type"`
|
||||
CreatorID string `json:"creator_id"`
|
||||
ParentIssueID *string `json:"parent_issue_id"`
|
||||
AcceptanceCriteria []any `json:"acceptance_criteria"`
|
||||
ContextRefs []any `json:"context_refs"`
|
||||
Repository any `json:"repository"`
|
||||
Position float64 `json:"position"`
|
||||
DueDate *string `json:"due_date"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func issueToResponse(i db.Issue) IssueResponse {
|
||||
var ac []any
|
||||
if i.AcceptanceCriteria != nil {
|
||||
json.Unmarshal(i.AcceptanceCriteria, &ac)
|
||||
}
|
||||
if ac == nil {
|
||||
ac = []any{}
|
||||
}
|
||||
|
||||
var cr []any
|
||||
if i.ContextRefs != nil {
|
||||
json.Unmarshal(i.ContextRefs, &cr)
|
||||
}
|
||||
if cr == nil {
|
||||
cr = []any{}
|
||||
}
|
||||
|
||||
var repo any
|
||||
if i.Repository != nil {
|
||||
json.Unmarshal(i.Repository, &repo)
|
||||
}
|
||||
|
||||
return IssueResponse{
|
||||
ID: uuidToString(i.ID),
|
||||
WorkspaceID: uuidToString(i.WorkspaceID),
|
||||
Title: i.Title,
|
||||
Description: textToPtr(i.Description),
|
||||
Status: i.Status,
|
||||
Priority: i.Priority,
|
||||
AssigneeType: textToPtr(i.AssigneeType),
|
||||
AssigneeID: uuidToPtr(i.AssigneeID),
|
||||
CreatorType: i.CreatorType,
|
||||
CreatorID: uuidToString(i.CreatorID),
|
||||
ParentIssueID: uuidToPtr(i.ParentIssueID),
|
||||
AcceptanceCriteria: ac,
|
||||
ContextRefs: cr,
|
||||
Repository: repo,
|
||||
Position: i.Position,
|
||||
DueDate: timestampToPtr(i.DueDate),
|
||||
CreatedAt: timestampToString(i.CreatedAt),
|
||||
UpdatedAt: timestampToString(i.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
workspaceID := r.URL.Query().Get("workspace_id")
|
||||
if workspaceID == "" {
|
||||
workspaceID = r.Header.Get("X-Workspace-ID")
|
||||
}
|
||||
if workspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
limit := 100
|
||||
offset := 0
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
if o := r.URL.Query().Get("offset"); o != "" {
|
||||
if v, err := strconv.Atoi(o); err == nil {
|
||||
offset = v
|
||||
}
|
||||
}
|
||||
|
||||
issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list issues")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]IssueResponse, len(issues))
|
||||
for i, issue := range issues {
|
||||
resp[i] = issueToResponse(issue)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"issues": resp,
|
||||
"total": len(resp),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
issue, err := h.Queries.GetIssue(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "issue not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, issueToResponse(issue))
|
||||
}
|
||||
|
||||
type CreateIssueRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
AssigneeType *string `json:"assignee_type"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
ParentIssueID *string `json:"parent_issue_id"`
|
||||
AcceptanceCriteria []any `json:"acceptance_criteria"`
|
||||
ContextRefs []any `json:"context_refs"`
|
||||
Repository any `json:"repository"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
var req CreateIssueRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Title == "" {
|
||||
writeError(w, http.StatusBadRequest, "title is required")
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID := r.URL.Query().Get("workspace_id")
|
||||
if workspaceID == "" {
|
||||
workspaceID = r.Header.Get("X-Workspace-ID")
|
||||
}
|
||||
if workspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Get creator from context (set by auth middleware)
|
||||
creatorID := r.Header.Get("X-User-ID")
|
||||
if creatorID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
status := req.Status
|
||||
if status == "" {
|
||||
status = "backlog"
|
||||
}
|
||||
priority := req.Priority
|
||||
if priority == "" {
|
||||
priority = "none"
|
||||
}
|
||||
|
||||
ac, _ := json.Marshal(req.AcceptanceCriteria)
|
||||
if req.AcceptanceCriteria == nil {
|
||||
ac = []byte("[]")
|
||||
}
|
||||
cr, _ := json.Marshal(req.ContextRefs)
|
||||
if req.ContextRefs == nil {
|
||||
cr = []byte("[]")
|
||||
}
|
||||
var repo []byte
|
||||
if req.Repository != nil {
|
||||
repo, _ = json.Marshal(req.Repository)
|
||||
}
|
||||
|
||||
var assigneeType pgtype.Text
|
||||
var assigneeID pgtype.UUID
|
||||
if req.AssigneeType != nil {
|
||||
assigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
|
||||
}
|
||||
if req.AssigneeID != nil {
|
||||
assigneeID = parseUUID(*req.AssigneeID)
|
||||
}
|
||||
|
||||
var parentIssueID pgtype.UUID
|
||||
if req.ParentIssueID != nil {
|
||||
parentIssueID = parseUUID(*req.ParentIssueID)
|
||||
}
|
||||
|
||||
issue, err := h.Queries.CreateIssue(r.Context(), db.CreateIssueParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Title: req.Title,
|
||||
Description: ptrToText(req.Description),
|
||||
Status: status,
|
||||
Priority: priority,
|
||||
AssigneeType: assigneeType,
|
||||
AssigneeID: assigneeID,
|
||||
CreatorType: "member",
|
||||
CreatorID: parseUUID(creatorID),
|
||||
ParentIssueID: parentIssueID,
|
||||
AcceptanceCriteria: ac,
|
||||
ContextRefs: cr,
|
||||
Repository: repo,
|
||||
Position: 0,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create issue: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := issueToResponse(issue)
|
||||
h.broadcast("issue:created", map[string]any{"issue": resp})
|
||||
|
||||
// Create inbox notification for assignee
|
||||
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
|
||||
inboxItem, err := h.Queries.CreateInboxItem(r.Context(), db.CreateInboxItemParams{
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
RecipientType: issue.AssigneeType.String,
|
||||
RecipientID: issue.AssigneeID,
|
||||
Type: "issue_assigned",
|
||||
Severity: "action_required",
|
||||
IssueID: issue.ID,
|
||||
Title: "New issue assigned: " + issue.Title,
|
||||
Body: ptrToText(req.Description),
|
||||
})
|
||||
if err == nil {
|
||||
h.broadcast("inbox:new", map[string]any{"item": inboxToResponse(inboxItem)})
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
type UpdateIssueRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *string `json:"status"`
|
||||
Priority *string `json:"priority"`
|
||||
AssigneeType *string `json:"assignee_type"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
Position *float64 `json:"position"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req UpdateIssueRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
params := db.UpdateIssueParams{
|
||||
ID: parseUUID(id),
|
||||
}
|
||||
|
||||
if req.Title != nil {
|
||||
params.Title = pgtype.Text{String: *req.Title, Valid: true}
|
||||
}
|
||||
if req.Description != nil {
|
||||
params.Description = pgtype.Text{String: *req.Description, Valid: true}
|
||||
}
|
||||
if req.Status != nil {
|
||||
params.Status = pgtype.Text{String: *req.Status, Valid: true}
|
||||
}
|
||||
if req.Priority != nil {
|
||||
params.Priority = pgtype.Text{String: *req.Priority, Valid: true}
|
||||
}
|
||||
if req.AssigneeType != nil {
|
||||
params.AssigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
|
||||
}
|
||||
if req.AssigneeID != nil {
|
||||
params.AssigneeID = parseUUID(*req.AssigneeID)
|
||||
}
|
||||
if req.Position != nil {
|
||||
params.Position = pgtype.Float8{Float64: *req.Position, Valid: true}
|
||||
}
|
||||
|
||||
issue, err := h.Queries.UpdateIssue(r.Context(), params)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update issue: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := issueToResponse(issue)
|
||||
h.broadcast("issue:updated", map[string]any{"issue": resp})
|
||||
|
||||
// If status changed, create a notification
|
||||
if req.Status != nil {
|
||||
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
|
||||
inboxItem, err := h.Queries.CreateInboxItem(r.Context(), db.CreateInboxItemParams{
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
RecipientType: issue.AssigneeType.String,
|
||||
RecipientID: issue.AssigneeID,
|
||||
Type: "status_change",
|
||||
Severity: "info",
|
||||
IssueID: issue.ID,
|
||||
Title: issue.Title + " moved to " + *req.Status,
|
||||
})
|
||||
if err == nil {
|
||||
h.broadcast("inbox:new", map[string]any{"item": inboxToResponse(inboxItem)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
err := h.Queries.DeleteIssue(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete issue")
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcast("issue:deleted", map[string]any{"issue_id": id})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
226
server/internal/handler/workspace.go
Normal file
226
server/internal/handler/workspace.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
type WorkspaceResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
Settings any `json:"settings"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func workspaceToResponse(w db.Workspace) WorkspaceResponse {
|
||||
var settings any
|
||||
if w.Settings != nil {
|
||||
json.Unmarshal(w.Settings, &settings)
|
||||
}
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
return WorkspaceResponse{
|
||||
ID: uuidToString(w.ID),
|
||||
Name: w.Name,
|
||||
Slug: w.Slug,
|
||||
Description: textToPtr(w.Description),
|
||||
Settings: settings,
|
||||
CreatedAt: timestampToString(w.CreatedAt),
|
||||
UpdatedAt: timestampToString(w.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
type MemberResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func memberToResponse(m db.Member) MemberResponse {
|
||||
return MemberResponse{
|
||||
ID: uuidToString(m.ID),
|
||||
WorkspaceID: uuidToString(m.WorkspaceID),
|
||||
UserID: uuidToString(m.UserID),
|
||||
Role: m.Role,
|
||||
CreatedAt: timestampToString(m.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
workspaces, err := h.Queries.ListWorkspaces(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list workspaces")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]WorkspaceResponse, len(workspaces))
|
||||
for i, ws := range workspaces {
|
||||
resp[i] = workspaceToResponse(ws)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) GetWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "workspace not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, workspaceToResponse(ws))
|
||||
}
|
||||
|
||||
type CreateWorkspaceRequest struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateWorkspaceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" || req.Slug == "" {
|
||||
writeError(w, http.StatusBadRequest, "name and slug are required")
|
||||
return
|
||||
}
|
||||
|
||||
ws, err := h.Queries.CreateWorkspace(r.Context(), db.CreateWorkspaceParams{
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
Description: ptrToText(req.Description),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create workspace: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Add creator as owner
|
||||
_, err = h.Queries.CreateMember(r.Context(), db.CreateMemberParams{
|
||||
WorkspaceID: ws.ID,
|
||||
UserID: parseUUID(userID),
|
||||
Role: "owner",
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to add owner: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, workspaceToResponse(ws))
|
||||
}
|
||||
|
||||
type UpdateWorkspaceRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Settings any `json:"settings"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req UpdateWorkspaceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
params := db.UpdateWorkspaceParams{
|
||||
ID: parseUUID(id),
|
||||
}
|
||||
if req.Name != nil {
|
||||
params.Name = pgtype.Text{String: *req.Name, Valid: true}
|
||||
}
|
||||
if req.Description != nil {
|
||||
params.Description = pgtype.Text{String: *req.Description, Valid: true}
|
||||
}
|
||||
if req.Settings != nil {
|
||||
s, _ := json.Marshal(req.Settings)
|
||||
params.Settings = s
|
||||
}
|
||||
|
||||
ws, err := h.Queries.UpdateWorkspace(r.Context(), params)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update workspace: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, workspaceToResponse(ws))
|
||||
}
|
||||
|
||||
func (h *Handler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
members, err := h.Queries.ListMembers(r.Context(), parseUUID(workspaceID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list members")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]MemberResponse, len(members))
|
||||
for i, m := range members {
|
||||
resp[i] = memberToResponse(m)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type MemberWithUserResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
func (h *Handler) ListMembersWithUser(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
members, err := h.Queries.ListMembersWithUser(r.Context(), parseUUID(workspaceID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list members")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]MemberWithUserResponse, len(members))
|
||||
for i, m := range members {
|
||||
resp[i] = MemberWithUserResponse{
|
||||
ID: uuidToString(m.ID),
|
||||
WorkspaceID: uuidToString(m.WorkspaceID),
|
||||
UserID: uuidToString(m.UserID),
|
||||
Role: m.Role,
|
||||
CreatedAt: timestampToString(m.CreatedAt),
|
||||
Name: m.UserName,
|
||||
Email: m.UserEmail,
|
||||
AvatarURL: textToPtr(m.UserAvatarUrl),
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
|
@ -2,14 +2,53 @@ package middleware
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var jwtSecret = []byte("multica-dev-secret-change-in-production")
|
||||
|
||||
// Auth middleware validates JWT tokens from the Authorization header.
|
||||
// TODO: Implement JWT validation.
|
||||
// Sets X-User-ID and X-User-Email headers on the request for downstream handlers.
|
||||
func Auth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Extract and validate JWT from Authorization header
|
||||
// For now, pass through all requests during development
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return jwtSecret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"invalid claims"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if sub, ok := claims["sub"].(string); ok {
|
||||
r.Header.Set("X-User-ID", sub)
|
||||
}
|
||||
if email, ok := claims["email"].(string); ok {
|
||||
r.Header.Set("X-User-Email", email)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
93
server/pkg/db/generated/activity.sql.go
Normal file
93
server/pkg/db/generated/activity.sql.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: activity.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createActivity = `-- name: CreateActivity :one
|
||||
INSERT INTO activity_log (
|
||||
workspace_id, issue_id, actor_type, actor_id, action, details
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at
|
||||
`
|
||||
|
||||
type CreateActivityParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
ActorType pgtype.Text `json:"actor_type"`
|
||||
ActorID pgtype.UUID `json:"actor_id"`
|
||||
Action string `json:"action"`
|
||||
Details []byte `json:"details"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateActivity(ctx context.Context, arg CreateActivityParams) (ActivityLog, error) {
|
||||
row := q.db.QueryRow(ctx, createActivity,
|
||||
arg.WorkspaceID,
|
||||
arg.IssueID,
|
||||
arg.ActorType,
|
||||
arg.ActorID,
|
||||
arg.Action,
|
||||
arg.Details,
|
||||
)
|
||||
var i ActivityLog
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.IssueID,
|
||||
&i.ActorType,
|
||||
&i.ActorID,
|
||||
&i.Action,
|
||||
&i.Details,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listActivities = `-- name: ListActivities :many
|
||||
SELECT id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at FROM activity_log
|
||||
WHERE issue_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
type ListActivitiesParams struct {
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListActivities(ctx context.Context, arg ListActivitiesParams) ([]ActivityLog, error) {
|
||||
rows, err := q.db.Query(ctx, listActivities, arg.IssueID, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ActivityLog{}
|
||||
for rows.Next() {
|
||||
var i ActivityLog
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.IssueID,
|
||||
&i.ActorType,
|
||||
&i.ActorID,
|
||||
&i.Action,
|
||||
&i.Details,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
184
server/pkg/db/generated/agent.sql.go
Normal file
184
server/pkg/db/generated/agent.sql.go
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: agent.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createAgent = `-- name: CreateAgent :one
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, avatar_url, runtime_mode,
|
||||
runtime_config, visibility, max_concurrent_tasks, owner_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateAgentParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
RuntimeConfig []byte `json:"runtime_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Agent, error) {
|
||||
row := q.db.QueryRow(ctx, createAgent,
|
||||
arg.WorkspaceID,
|
||||
arg.Name,
|
||||
arg.AvatarUrl,
|
||||
arg.RuntimeMode,
|
||||
arg.RuntimeConfig,
|
||||
arg.Visibility,
|
||||
arg.MaxConcurrentTasks,
|
||||
arg.OwnerID,
|
||||
)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.AvatarUrl,
|
||||
&i.RuntimeMode,
|
||||
&i.RuntimeConfig,
|
||||
&i.Visibility,
|
||||
&i.Status,
|
||||
&i.MaxConcurrentTasks,
|
||||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteAgent = `-- name: DeleteAgent :exec
|
||||
DELETE FROM agent WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteAgent(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteAgent, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAgent = `-- name: GetAgent :one
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at FROM agent
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetAgent(ctx context.Context, id pgtype.UUID) (Agent, error) {
|
||||
row := q.db.QueryRow(ctx, getAgent, id)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.AvatarUrl,
|
||||
&i.RuntimeMode,
|
||||
&i.RuntimeConfig,
|
||||
&i.Visibility,
|
||||
&i.Status,
|
||||
&i.MaxConcurrentTasks,
|
||||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAgents = `-- name: ListAgents :many
|
||||
SELECT id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at FROM agent
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListAgents(ctx context.Context, workspaceID pgtype.UUID) ([]Agent, error) {
|
||||
rows, err := q.db.Query(ctx, listAgents, workspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Agent{}
|
||||
for rows.Next() {
|
||||
var i Agent
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.AvatarUrl,
|
||||
&i.RuntimeMode,
|
||||
&i.RuntimeConfig,
|
||||
&i.Visibility,
|
||||
&i.Status,
|
||||
&i.MaxConcurrentTasks,
|
||||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateAgent = `-- name: UpdateAgent :one
|
||||
UPDATE agent SET
|
||||
name = COALESCE($2, name),
|
||||
avatar_url = COALESCE($3, avatar_url),
|
||||
runtime_config = COALESCE($4, runtime_config),
|
||||
visibility = COALESCE($5, visibility),
|
||||
status = COALESCE($6, status),
|
||||
max_concurrent_tasks = COALESCE($7, max_concurrent_tasks),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, name, avatar_url, runtime_mode, runtime_config, visibility, status, max_concurrent_tasks, owner_id, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateAgentParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
RuntimeConfig []byte `json:"runtime_config"`
|
||||
Visibility pgtype.Text `json:"visibility"`
|
||||
Status pgtype.Text `json:"status"`
|
||||
MaxConcurrentTasks pgtype.Int4 `json:"max_concurrent_tasks"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) (Agent, error) {
|
||||
row := q.db.QueryRow(ctx, updateAgent,
|
||||
arg.ID,
|
||||
arg.Name,
|
||||
arg.AvatarUrl,
|
||||
arg.RuntimeConfig,
|
||||
arg.Visibility,
|
||||
arg.Status,
|
||||
arg.MaxConcurrentTasks,
|
||||
)
|
||||
var i Agent
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Name,
|
||||
&i.AvatarUrl,
|
||||
&i.RuntimeMode,
|
||||
&i.RuntimeConfig,
|
||||
&i.Visibility,
|
||||
&i.Status,
|
||||
&i.MaxConcurrentTasks,
|
||||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
142
server/pkg/db/generated/comment.sql.go
Normal file
142
server/pkg/db/generated/comment.sql.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: comment.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createComment = `-- name: CreateComment :one
|
||||
INSERT INTO comment (issue_id, author_type, author_id, content, type)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateCommentParams struct {
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
AuthorType string `json:"author_type"`
|
||||
AuthorID pgtype.UUID `json:"author_id"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateComment(ctx context.Context, arg CreateCommentParams) (Comment, error) {
|
||||
row := q.db.QueryRow(ctx, createComment,
|
||||
arg.IssueID,
|
||||
arg.AuthorType,
|
||||
arg.AuthorID,
|
||||
arg.Content,
|
||||
arg.Type,
|
||||
)
|
||||
var i Comment
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.IssueID,
|
||||
&i.AuthorType,
|
||||
&i.AuthorID,
|
||||
&i.Content,
|
||||
&i.Type,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteComment = `-- name: DeleteComment :exec
|
||||
DELETE FROM comment WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteComment(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteComment, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getComment = `-- name: GetComment :one
|
||||
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at FROM comment
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetComment(ctx context.Context, id pgtype.UUID) (Comment, error) {
|
||||
row := q.db.QueryRow(ctx, getComment, id)
|
||||
var i Comment
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.IssueID,
|
||||
&i.AuthorType,
|
||||
&i.AuthorID,
|
||||
&i.Content,
|
||||
&i.Type,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listComments = `-- name: ListComments :many
|
||||
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at FROM comment
|
||||
WHERE issue_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListComments(ctx context.Context, issueID pgtype.UUID) ([]Comment, error) {
|
||||
rows, err := q.db.Query(ctx, listComments, issueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Comment{}
|
||||
for rows.Next() {
|
||||
var i Comment
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.IssueID,
|
||||
&i.AuthorType,
|
||||
&i.AuthorID,
|
||||
&i.Content,
|
||||
&i.Type,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateComment = `-- name: UpdateComment :one
|
||||
UPDATE comment SET
|
||||
content = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateCommentParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateComment(ctx context.Context, arg UpdateCommentParams) (Comment, error) {
|
||||
row := q.db.QueryRow(ctx, updateComment, arg.ID, arg.Content)
|
||||
var i Comment
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.IssueID,
|
||||
&i.AuthorType,
|
||||
&i.AuthorID,
|
||||
&i.Content,
|
||||
&i.Type,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
32
server/pkg/db/generated/db.go
Normal file
32
server/pkg/db/generated/db.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
206
server/pkg/db/generated/inbox.sql.go
Normal file
206
server/pkg/db/generated/inbox.sql.go
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: inbox.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const archiveInboxItem = `-- name: ArchiveInboxItem :one
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at
|
||||
`
|
||||
|
||||
func (q *Queries) ArchiveInboxItem(ctx context.Context, id pgtype.UUID) (InboxItem, error) {
|
||||
row := q.db.QueryRow(ctx, archiveInboxItem, id)
|
||||
var i InboxItem
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.RecipientType,
|
||||
&i.RecipientID,
|
||||
&i.Type,
|
||||
&i.Severity,
|
||||
&i.IssueID,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const countUnreadInbox = `-- name: CountUnreadInbox :one
|
||||
SELECT count(*) FROM inbox_item
|
||||
WHERE recipient_type = $1 AND recipient_id = $2 AND read = false AND archived = false
|
||||
`
|
||||
|
||||
type CountUnreadInboxParams struct {
|
||||
RecipientType string `json:"recipient_type"`
|
||||
RecipientID pgtype.UUID `json:"recipient_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CountUnreadInbox(ctx context.Context, arg CountUnreadInboxParams) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countUnreadInbox, arg.RecipientType, arg.RecipientID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createInboxItem = `-- name: CreateInboxItem :one
|
||||
INSERT INTO inbox_item (
|
||||
workspace_id, recipient_type, recipient_id,
|
||||
type, severity, issue_id, title, body
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at
|
||||
`
|
||||
|
||||
type CreateInboxItemParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RecipientType string `json:"recipient_type"`
|
||||
RecipientID pgtype.UUID `json:"recipient_id"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Title string `json:"title"`
|
||||
Body pgtype.Text `json:"body"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateInboxItem(ctx context.Context, arg CreateInboxItemParams) (InboxItem, error) {
|
||||
row := q.db.QueryRow(ctx, createInboxItem,
|
||||
arg.WorkspaceID,
|
||||
arg.RecipientType,
|
||||
arg.RecipientID,
|
||||
arg.Type,
|
||||
arg.Severity,
|
||||
arg.IssueID,
|
||||
arg.Title,
|
||||
arg.Body,
|
||||
)
|
||||
var i InboxItem
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.RecipientType,
|
||||
&i.RecipientID,
|
||||
&i.Type,
|
||||
&i.Severity,
|
||||
&i.IssueID,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getInboxItem = `-- name: GetInboxItem :one
|
||||
SELECT id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at FROM inbox_item
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetInboxItem(ctx context.Context, id pgtype.UUID) (InboxItem, error) {
|
||||
row := q.db.QueryRow(ctx, getInboxItem, id)
|
||||
var i InboxItem
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.RecipientType,
|
||||
&i.RecipientID,
|
||||
&i.Type,
|
||||
&i.Severity,
|
||||
&i.IssueID,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listInboxItems = `-- name: ListInboxItems :many
|
||||
SELECT id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at FROM inbox_item
|
||||
WHERE recipient_type = $1 AND recipient_id = $2 AND archived = false
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
type ListInboxItemsParams struct {
|
||||
RecipientType string `json:"recipient_type"`
|
||||
RecipientID pgtype.UUID `json:"recipient_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListInboxItems(ctx context.Context, arg ListInboxItemsParams) ([]InboxItem, error) {
|
||||
rows, err := q.db.Query(ctx, listInboxItems,
|
||||
arg.RecipientType,
|
||||
arg.RecipientID,
|
||||
arg.Limit,
|
||||
arg.Offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []InboxItem{}
|
||||
for rows.Next() {
|
||||
var i InboxItem
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.RecipientType,
|
||||
&i.RecipientID,
|
||||
&i.Type,
|
||||
&i.Severity,
|
||||
&i.IssueID,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markInboxRead = `-- name: MarkInboxRead :one
|
||||
UPDATE inbox_item SET read = true
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, recipient_type, recipient_id, type, severity, issue_id, title, body, read, archived, created_at
|
||||
`
|
||||
|
||||
func (q *Queries) MarkInboxRead(ctx context.Context, id pgtype.UUID) (InboxItem, error) {
|
||||
row := q.db.QueryRow(ctx, markInboxRead, id)
|
||||
var i InboxItem
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.RecipientType,
|
||||
&i.RecipientID,
|
||||
&i.Type,
|
||||
&i.Severity,
|
||||
&i.IssueID,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Read,
|
||||
&i.Archived,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
233
server/pkg/db/generated/issue.sql.go
Normal file
233
server/pkg/db/generated/issue.sql.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: issue.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createIssue = `-- name: CreateIssue :one
|
||||
INSERT INTO issue (
|
||||
workspace_id, title, description, status, priority,
|
||||
assignee_type, assignee_id, creator_type, creator_id,
|
||||
parent_issue_id, acceptance_criteria, context_refs,
|
||||
repository, position
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
|
||||
) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, repository, position, due_date, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateIssueParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Title string `json:"title"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
AssigneeType pgtype.Text `json:"assignee_type"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
CreatorType string `json:"creator_type"`
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
|
||||
AcceptanceCriteria []byte `json:"acceptance_criteria"`
|
||||
ContextRefs []byte `json:"context_refs"`
|
||||
Repository []byte `json:"repository"`
|
||||
Position float64 `json:"position"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) {
|
||||
row := q.db.QueryRow(ctx, createIssue,
|
||||
arg.WorkspaceID,
|
||||
arg.Title,
|
||||
arg.Description,
|
||||
arg.Status,
|
||||
arg.Priority,
|
||||
arg.AssigneeType,
|
||||
arg.AssigneeID,
|
||||
arg.CreatorType,
|
||||
arg.CreatorID,
|
||||
arg.ParentIssueID,
|
||||
arg.AcceptanceCriteria,
|
||||
arg.ContextRefs,
|
||||
arg.Repository,
|
||||
arg.Position,
|
||||
)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.Priority,
|
||||
&i.AssigneeType,
|
||||
&i.AssigneeID,
|
||||
&i.CreatorType,
|
||||
&i.CreatorID,
|
||||
&i.ParentIssueID,
|
||||
&i.AcceptanceCriteria,
|
||||
&i.ContextRefs,
|
||||
&i.Repository,
|
||||
&i.Position,
|
||||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteIssue = `-- name: DeleteIssue :exec
|
||||
DELETE FROM issue WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteIssue(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteIssue, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getIssue = `-- name: GetIssue :one
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, repository, position, due_date, created_at, updated_at FROM issue
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) {
|
||||
row := q.db.QueryRow(ctx, getIssue, id)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.Priority,
|
||||
&i.AssigneeType,
|
||||
&i.AssigneeID,
|
||||
&i.CreatorType,
|
||||
&i.CreatorID,
|
||||
&i.ParentIssueID,
|
||||
&i.AcceptanceCriteria,
|
||||
&i.ContextRefs,
|
||||
&i.Repository,
|
||||
&i.Position,
|
||||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listIssues = `-- name: ListIssues :many
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, repository, position, due_date, created_at, updated_at FROM issue
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY position ASC, created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
type ListIssuesParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue, error) {
|
||||
rows, err := q.db.Query(ctx, listIssues, arg.WorkspaceID, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Issue{}
|
||||
for rows.Next() {
|
||||
var i Issue
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.Priority,
|
||||
&i.AssigneeType,
|
||||
&i.AssigneeID,
|
||||
&i.CreatorType,
|
||||
&i.CreatorID,
|
||||
&i.ParentIssueID,
|
||||
&i.AcceptanceCriteria,
|
||||
&i.ContextRefs,
|
||||
&i.Repository,
|
||||
&i.Position,
|
||||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateIssue = `-- name: UpdateIssue :one
|
||||
UPDATE issue SET
|
||||
title = COALESCE($2, title),
|
||||
description = COALESCE($3, description),
|
||||
status = COALESCE($4, status),
|
||||
priority = COALESCE($5, priority),
|
||||
assignee_type = $6,
|
||||
assignee_id = $7,
|
||||
position = COALESCE($8, position),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, repository, position, due_date, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateIssueParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Title pgtype.Text `json:"title"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Status pgtype.Text `json:"status"`
|
||||
Priority pgtype.Text `json:"priority"`
|
||||
AssigneeType pgtype.Text `json:"assignee_type"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
Position pgtype.Float8 `json:"position"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) {
|
||||
row := q.db.QueryRow(ctx, updateIssue,
|
||||
arg.ID,
|
||||
arg.Title,
|
||||
arg.Description,
|
||||
arg.Status,
|
||||
arg.Priority,
|
||||
arg.AssigneeType,
|
||||
arg.AssigneeID,
|
||||
arg.Position,
|
||||
)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Status,
|
||||
&i.Priority,
|
||||
&i.AssigneeType,
|
||||
&i.AssigneeID,
|
||||
&i.CreatorType,
|
||||
&i.CreatorID,
|
||||
&i.ParentIssueID,
|
||||
&i.AcceptanceCriteria,
|
||||
&i.ContextRefs,
|
||||
&i.Repository,
|
||||
&i.Position,
|
||||
&i.DueDate,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
192
server/pkg/db/generated/member.sql.go
Normal file
192
server/pkg/db/generated/member.sql.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: member.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createMember = `-- name: CreateMember :one
|
||||
INSERT INTO member (workspace_id, user_id, role)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, workspace_id, user_id, role, created_at
|
||||
`
|
||||
|
||||
type CreateMemberParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateMember(ctx context.Context, arg CreateMemberParams) (Member, error) {
|
||||
row := q.db.QueryRow(ctx, createMember, arg.WorkspaceID, arg.UserID, arg.Role)
|
||||
var i Member
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.UserID,
|
||||
&i.Role,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteMember = `-- name: DeleteMember :exec
|
||||
DELETE FROM member WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteMember(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteMember, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getMember = `-- name: GetMember :one
|
||||
SELECT id, workspace_id, user_id, role, created_at FROM member
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetMember(ctx context.Context, id pgtype.UUID) (Member, error) {
|
||||
row := q.db.QueryRow(ctx, getMember, id)
|
||||
var i Member
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.UserID,
|
||||
&i.Role,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getMemberByUserAndWorkspace = `-- name: GetMemberByUserAndWorkspace :one
|
||||
SELECT id, workspace_id, user_id, role, created_at FROM member
|
||||
WHERE user_id = $1 AND workspace_id = $2
|
||||
`
|
||||
|
||||
type GetMemberByUserAndWorkspaceParams struct {
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetMemberByUserAndWorkspace(ctx context.Context, arg GetMemberByUserAndWorkspaceParams) (Member, error) {
|
||||
row := q.db.QueryRow(ctx, getMemberByUserAndWorkspace, arg.UserID, arg.WorkspaceID)
|
||||
var i Member
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.UserID,
|
||||
&i.Role,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listMembers = `-- name: ListMembers :many
|
||||
SELECT id, workspace_id, user_id, role, created_at FROM member
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListMembers(ctx context.Context, workspaceID pgtype.UUID) ([]Member, error) {
|
||||
rows, err := q.db.Query(ctx, listMembers, workspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Member{}
|
||||
for rows.Next() {
|
||||
var i Member
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.UserID,
|
||||
&i.Role,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listMembersWithUser = `-- name: ListMembersWithUser :many
|
||||
SELECT m.id, m.workspace_id, m.user_id, m.role, m.created_at,
|
||||
u.name as user_name, u.email as user_email, u.avatar_url as user_avatar_url
|
||||
FROM member m
|
||||
JOIN "user" u ON u.id = m.user_id
|
||||
WHERE m.workspace_id = $1
|
||||
ORDER BY m.created_at ASC
|
||||
`
|
||||
|
||||
type ListMembersWithUserRow struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UserName string `json:"user_name"`
|
||||
UserEmail string `json:"user_email"`
|
||||
UserAvatarUrl pgtype.Text `json:"user_avatar_url"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListMembersWithUser(ctx context.Context, workspaceID pgtype.UUID) ([]ListMembersWithUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listMembersWithUser, workspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListMembersWithUserRow{}
|
||||
for rows.Next() {
|
||||
var i ListMembersWithUserRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.UserID,
|
||||
&i.Role,
|
||||
&i.CreatedAt,
|
||||
&i.UserName,
|
||||
&i.UserEmail,
|
||||
&i.UserAvatarUrl,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateMemberRole = `-- name: UpdateMemberRole :one
|
||||
UPDATE member SET role = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, user_id, role, created_at
|
||||
`
|
||||
|
||||
type UpdateMemberRoleParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateMemberRole(ctx context.Context, arg UpdateMemberRoleParams) (Member, error) {
|
||||
row := q.db.QueryRow(ctx, updateMemberRole, arg.ID, arg.Role)
|
||||
var i Member
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.UserID,
|
||||
&i.Role,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
153
server/pkg/db/generated/models.go
Normal file
153
server/pkg/db/generated/models.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type ActivityLog struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
ActorType pgtype.Text `json:"actor_type"`
|
||||
ActorID pgtype.UUID `json:"actor_id"`
|
||||
Action string `json:"action"`
|
||||
Details []byte `json:"details"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
RuntimeConfig []byte `json:"runtime_config"`
|
||||
Visibility string `json:"visibility"`
|
||||
Status string `json:"status"`
|
||||
MaxConcurrentTasks int32 `json:"max_concurrent_tasks"`
|
||||
OwnerID pgtype.UUID `json:"owner_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AgentTaskQueue struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Status string `json:"status"`
|
||||
Priority int32 `json:"priority"`
|
||||
DispatchedAt pgtype.Timestamptz `json:"dispatched_at"`
|
||||
StartedAt pgtype.Timestamptz `json:"started_at"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
Result []byte `json:"result"`
|
||||
Error pgtype.Text `json:"error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
AuthorType string `json:"author_type"`
|
||||
AuthorID pgtype.UUID `json:"author_id"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type DaemonConnection struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
Status string `json:"status"`
|
||||
LastHeartbeatAt pgtype.Timestamptz `json:"last_heartbeat_at"`
|
||||
RuntimeInfo []byte `json:"runtime_info"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type InboxItem struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
RecipientType string `json:"recipient_type"`
|
||||
RecipientID pgtype.UUID `json:"recipient_id"`
|
||||
Type string `json:"type"`
|
||||
Severity string `json:"severity"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
Title string `json:"title"`
|
||||
Body pgtype.Text `json:"body"`
|
||||
Read bool `json:"read"`
|
||||
Archived bool `json:"archived"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Title string `json:"title"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
AssigneeType pgtype.Text `json:"assignee_type"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
CreatorType string `json:"creator_type"`
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
ParentIssueID pgtype.UUID `json:"parent_issue_id"`
|
||||
AcceptanceCriteria []byte `json:"acceptance_criteria"`
|
||||
ContextRefs []byte `json:"context_refs"`
|
||||
Repository []byte `json:"repository"`
|
||||
Position float64 `json:"position"`
|
||||
DueDate pgtype.Timestamptz `json:"due_date"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type IssueDependency struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
DependsOnIssueID pgtype.UUID `json:"depends_on_issue_id"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type IssueLabel struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
type IssueToLabel struct {
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
LabelID pgtype.UUID `json:"label_id"`
|
||||
}
|
||||
|
||||
type Member struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Workspace struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Settings []byte `json:"settings"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
105
server/pkg/db/generated/user.sql.go
Normal file
105
server/pkg/db/generated/user.sql.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: user.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO "user" (name, email, avatar_url)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, email, avatar_url, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, createUser, arg.Name, arg.Email, arg.AvatarUrl)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Email,
|
||||
&i.AvatarUrl,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUser = `-- name: GetUser :one
|
||||
SELECT id, name, email, avatar_url, created_at, updated_at FROM "user"
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getUser, id)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Email,
|
||||
&i.AvatarUrl,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||
SELECT id, name, email, avatar_url, created_at, updated_at FROM "user"
|
||||
WHERE email = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByEmail, email)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Email,
|
||||
&i.AvatarUrl,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateUser = `-- name: UpdateUser :one
|
||||
UPDATE "user" SET
|
||||
name = COALESCE($2, name),
|
||||
avatar_url = COALESCE($3, avatar_url),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, email, avatar_url, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateUserParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, updateUser, arg.ID, arg.Name, arg.AvatarUrl)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Email,
|
||||
&i.AvatarUrl,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
160
server/pkg/db/generated/workspace.sql.go
Normal file
160
server/pkg/db/generated/workspace.sql.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: workspace.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createWorkspace = `-- name: CreateWorkspace :one
|
||||
INSERT INTO workspace (name, slug, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, slug, description, settings, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateWorkspaceParams struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) {
|
||||
row := q.db.QueryRow(ctx, createWorkspace, arg.Name, arg.Slug, arg.Description)
|
||||
var i Workspace
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Slug,
|
||||
&i.Description,
|
||||
&i.Settings,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteWorkspace = `-- name: DeleteWorkspace :exec
|
||||
DELETE FROM workspace WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteWorkspace(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteWorkspace, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getWorkspace = `-- name: GetWorkspace :one
|
||||
SELECT id, name, slug, description, settings, created_at, updated_at FROM workspace
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetWorkspace(ctx context.Context, id pgtype.UUID) (Workspace, error) {
|
||||
row := q.db.QueryRow(ctx, getWorkspace, id)
|
||||
var i Workspace
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Slug,
|
||||
&i.Description,
|
||||
&i.Settings,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWorkspaceBySlug = `-- name: GetWorkspaceBySlug :one
|
||||
SELECT id, name, slug, description, settings, created_at, updated_at FROM workspace
|
||||
WHERE slug = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetWorkspaceBySlug(ctx context.Context, slug string) (Workspace, error) {
|
||||
row := q.db.QueryRow(ctx, getWorkspaceBySlug, slug)
|
||||
var i Workspace
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Slug,
|
||||
&i.Description,
|
||||
&i.Settings,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listWorkspaces = `-- name: ListWorkspaces :many
|
||||
SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at FROM workspace w
|
||||
JOIN member m ON m.workspace_id = w.id
|
||||
WHERE m.user_id = $1
|
||||
ORDER BY w.created_at ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListWorkspaces(ctx context.Context, userID pgtype.UUID) ([]Workspace, error) {
|
||||
rows, err := q.db.Query(ctx, listWorkspaces, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Workspace{}
|
||||
for rows.Next() {
|
||||
var i Workspace
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Slug,
|
||||
&i.Description,
|
||||
&i.Settings,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateWorkspace = `-- name: UpdateWorkspace :one
|
||||
UPDATE workspace SET
|
||||
name = COALESCE($2, name),
|
||||
description = COALESCE($3, description),
|
||||
settings = COALESCE($4, settings),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, slug, description, settings, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateWorkspaceParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Settings []byte `json:"settings"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) {
|
||||
row := q.db.QueryRow(ctx, updateWorkspace,
|
||||
arg.ID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Settings,
|
||||
)
|
||||
var i Workspace
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Slug,
|
||||
&i.Description,
|
||||
&i.Settings,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
11
server/pkg/db/queries/activity.sql
Normal file
11
server/pkg/db/queries/activity.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- name: ListActivities :many
|
||||
SELECT * FROM activity_log
|
||||
WHERE issue_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
-- name: CreateActivity :one
|
||||
INSERT INTO activity_log (
|
||||
workspace_id, issue_id, actor_type, actor_id, action, details
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *;
|
||||
30
server/pkg/db/queries/agent.sql
Normal file
30
server/pkg/db/queries/agent.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
-- name: ListAgents :many
|
||||
SELECT * FROM agent
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: GetAgent :one
|
||||
SELECT * FROM agent
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CreateAgent :one
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, avatar_url, runtime_mode,
|
||||
runtime_config, visibility, max_concurrent_tasks, owner_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateAgent :one
|
||||
UPDATE agent SET
|
||||
name = COALESCE(sqlc.narg('name'), name),
|
||||
avatar_url = COALESCE(sqlc.narg('avatar_url'), avatar_url),
|
||||
runtime_config = COALESCE(sqlc.narg('runtime_config'), runtime_config),
|
||||
visibility = COALESCE(sqlc.narg('visibility'), visibility),
|
||||
status = COALESCE(sqlc.narg('status'), status),
|
||||
max_concurrent_tasks = COALESCE(sqlc.narg('max_concurrent_tasks'), max_concurrent_tasks),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteAgent :exec
|
||||
DELETE FROM agent WHERE id = $1;
|
||||
23
server/pkg/db/queries/comment.sql
Normal file
23
server/pkg/db/queries/comment.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
-- name: ListComments :many
|
||||
SELECT * FROM comment
|
||||
WHERE issue_id = $1
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: GetComment :one
|
||||
SELECT * FROM comment
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CreateComment :one
|
||||
INSERT INTO comment (issue_id, author_type, author_id, content, type)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateComment :one
|
||||
UPDATE comment SET
|
||||
content = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteComment :exec
|
||||
DELETE FROM comment WHERE id = $1;
|
||||
30
server/pkg/db/queries/inbox.sql
Normal file
30
server/pkg/db/queries/inbox.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
-- name: ListInboxItems :many
|
||||
SELECT * FROM inbox_item
|
||||
WHERE recipient_type = $1 AND recipient_id = $2 AND archived = false
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: GetInboxItem :one
|
||||
SELECT * FROM inbox_item
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CreateInboxItem :one
|
||||
INSERT INTO inbox_item (
|
||||
workspace_id, recipient_type, recipient_id,
|
||||
type, severity, issue_id, title, body
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *;
|
||||
|
||||
-- name: MarkInboxRead :one
|
||||
UPDATE inbox_item SET read = true
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ArchiveInboxItem :one
|
||||
UPDATE inbox_item SET archived = true
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: CountUnreadInbox :one
|
||||
SELECT count(*) FROM inbox_item
|
||||
WHERE recipient_type = $1 AND recipient_id = $2 AND read = false AND archived = false;
|
||||
|
|
@ -20,13 +20,13 @@ INSERT INTO issue (
|
|||
|
||||
-- name: UpdateIssue :one
|
||||
UPDATE issue SET
|
||||
title = COALESCE($2, title),
|
||||
description = COALESCE($3, description),
|
||||
status = COALESCE($4, status),
|
||||
priority = COALESCE($5, priority),
|
||||
assignee_type = $6,
|
||||
assignee_id = $7,
|
||||
position = COALESCE($8, position),
|
||||
title = COALESCE(sqlc.narg('title'), title),
|
||||
description = COALESCE(sqlc.narg('description'), description),
|
||||
status = COALESCE(sqlc.narg('status'), status),
|
||||
priority = COALESCE(sqlc.narg('priority'), priority),
|
||||
assignee_type = sqlc.narg('assignee_type'),
|
||||
assignee_id = sqlc.narg('assignee_id'),
|
||||
position = COALESCE(sqlc.narg('position'), position),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
|
|
|||
33
server/pkg/db/queries/member.sql
Normal file
33
server/pkg/db/queries/member.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
-- name: ListMembers :many
|
||||
SELECT * FROM member
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: GetMember :one
|
||||
SELECT * FROM member
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetMemberByUserAndWorkspace :one
|
||||
SELECT * FROM member
|
||||
WHERE user_id = $1 AND workspace_id = $2;
|
||||
|
||||
-- name: CreateMember :one
|
||||
INSERT INTO member (workspace_id, user_id, role)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateMemberRole :one
|
||||
UPDATE member SET role = $2
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteMember :exec
|
||||
DELETE FROM member WHERE id = $1;
|
||||
|
||||
-- name: ListMembersWithUser :many
|
||||
SELECT m.id, m.workspace_id, m.user_id, m.role, m.created_at,
|
||||
u.name as user_name, u.email as user_email, u.avatar_url as user_avatar_url
|
||||
FROM member m
|
||||
JOIN "user" u ON u.id = m.user_id
|
||||
WHERE m.workspace_id = $1
|
||||
ORDER BY m.created_at ASC;
|
||||
20
server/pkg/db/queries/user.sql
Normal file
20
server/pkg/db/queries/user.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- name: GetUser :one
|
||||
SELECT * FROM "user"
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetUserByEmail :one
|
||||
SELECT * FROM "user"
|
||||
WHERE email = $1;
|
||||
|
||||
-- name: CreateUser :one
|
||||
INSERT INTO "user" (name, email, avatar_url)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateUser :one
|
||||
UPDATE "user" SET
|
||||
name = COALESCE($2, name),
|
||||
avatar_url = COALESCE($3, avatar_url),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
30
server/pkg/db/queries/workspace.sql
Normal file
30
server/pkg/db/queries/workspace.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
-- name: ListWorkspaces :many
|
||||
SELECT w.* FROM workspace w
|
||||
JOIN member m ON m.workspace_id = w.id
|
||||
WHERE m.user_id = $1
|
||||
ORDER BY w.created_at ASC;
|
||||
|
||||
-- name: GetWorkspace :one
|
||||
SELECT * FROM workspace
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetWorkspaceBySlug :one
|
||||
SELECT * FROM workspace
|
||||
WHERE slug = $1;
|
||||
|
||||
-- name: CreateWorkspace :one
|
||||
INSERT INTO workspace (name, slug, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateWorkspace :one
|
||||
UPDATE workspace SET
|
||||
name = COALESCE(sqlc.narg('name'), name),
|
||||
description = COALESCE(sqlc.narg('description'), description),
|
||||
settings = COALESCE(sqlc.narg('settings'), settings),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteWorkspace :exec
|
||||
DELETE FROM workspace WHERE id = $1;
|
||||
Loading…
Add table
Add a link
Reference in a new issue