Files
multica/server/internal/handler/project.go
Bohan Jiang 68e2a14ba2 feat(projects): add Project entity with full-stack CRUD support (#552)
Implements the Project concept as a higher-level grouping for issues.
Hierarchy: workspace → project → issue → sub-issue.

Backend:
- Migration 034: project table + issue.project_id FK
- sqlc queries for project CRUD
- Project handler with list/get/create/update/delete
- Issue handler updated to support project_id in create/update
- Routes at /api/projects, WebSocket event constants

Frontend (new monorepo structure):
- @multica/core: Project types, API client methods, queries/mutations,
  status config, realtime sync
- @multica/views: Projects list page, detail page (overview + issues
  tabs), project picker for issue detail panel
- apps/web: Route pages, sidebar navigation entry

All TypeScript type checks and tests pass.
2026-04-09 14:59:16 +08:00

237 lines
7.0 KiB
Go

package handler
import (
"encoding/json"
"io"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
type ProjectResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Title string `json:"title"`
Description *string `json:"description"`
Icon *string `json:"icon"`
Status string `json:"status"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func projectToResponse(p db.Project) ProjectResponse {
return ProjectResponse{
ID: uuidToString(p.ID),
WorkspaceID: uuidToString(p.WorkspaceID),
Title: p.Title,
Description: textToPtr(p.Description),
Icon: textToPtr(p.Icon),
Status: p.Status,
LeadType: textToPtr(p.LeadType),
LeadID: uuidToPtr(p.LeadID),
CreatedAt: timestampToString(p.CreatedAt),
UpdatedAt: timestampToString(p.UpdatedAt),
}
}
type CreateProjectRequest struct {
Title string `json:"title"`
Description *string `json:"description"`
Icon *string `json:"icon"`
Status string `json:"status"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
}
type UpdateProjectRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Icon *string `json:"icon"`
Status *string `json:"status"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
}
func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
workspaceID := resolveWorkspaceID(r)
var statusFilter pgtype.Text
if s := r.URL.Query().Get("status"); s != "" {
statusFilter = pgtype.Text{String: s, Valid: true}
}
projects, err := h.Queries.ListProjects(r.Context(), db.ListProjectsParams{
WorkspaceID: parseUUID(workspaceID),
Status: statusFilter,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list projects")
return
}
resp := make([]ProjectResponse, len(projects))
for i, p := range projects {
resp[i] = projectToResponse(p)
}
writeJSON(w, http.StatusOK, map[string]any{"projects": resp, "total": len(resp)})
}
func (h *Handler) GetProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := resolveWorkspaceID(r)
project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
ID: parseUUID(id), WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
writeJSON(w, http.StatusOK, projectToResponse(project))
}
func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
var req CreateProjectRequest
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 := resolveWorkspaceID(r)
userID, ok := requireUserID(w, r)
if !ok {
return
}
status := req.Status
if status == "" {
status = "planned"
}
var leadType pgtype.Text
var leadID pgtype.UUID
if req.LeadType != nil {
leadType = pgtype.Text{String: *req.LeadType, Valid: true}
}
if req.LeadID != nil {
leadID = parseUUID(*req.LeadID)
}
project, err := h.Queries.CreateProject(r.Context(), db.CreateProjectParams{
WorkspaceID: parseUUID(workspaceID),
Title: req.Title,
Description: ptrToText(req.Description),
Icon: ptrToText(req.Icon),
Status: status,
LeadType: leadType,
LeadID: leadID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create project")
return
}
resp := projectToResponse(project)
h.publish(protocol.EventProjectCreated, workspaceID, "member", userID, map[string]any{"project": resp})
writeJSON(w, http.StatusCreated, resp)
}
func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := resolveWorkspaceID(r)
prevProject, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
ID: parseUUID(id), WorkspaceID: parseUUID(workspaceID),
})
if err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read request body")
return
}
var req UpdateProjectRequest
if err := json.Unmarshal(bodyBytes, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var rawFields map[string]json.RawMessage
json.Unmarshal(bodyBytes, &rawFields)
params := db.UpdateProjectParams{
ID: prevProject.ID,
Description: prevProject.Description,
Icon: prevProject.Icon,
LeadType: prevProject.LeadType,
LeadID: prevProject.LeadID,
}
if req.Title != nil {
params.Title = pgtype.Text{String: *req.Title, Valid: true}
}
if req.Status != nil {
params.Status = pgtype.Text{String: *req.Status, Valid: true}
}
if _, ok := rawFields["description"]; ok {
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
} else {
params.Description = pgtype.Text{Valid: false}
}
}
if _, ok := rawFields["icon"]; ok {
if req.Icon != nil {
params.Icon = pgtype.Text{String: *req.Icon, Valid: true}
} else {
params.Icon = pgtype.Text{Valid: false}
}
}
if _, ok := rawFields["lead_type"]; ok {
if req.LeadType != nil {
params.LeadType = pgtype.Text{String: *req.LeadType, Valid: true}
} else {
params.LeadType = pgtype.Text{Valid: false}
}
}
if _, ok := rawFields["lead_id"]; ok {
if req.LeadID != nil {
params.LeadID = parseUUID(*req.LeadID)
} else {
params.LeadID = pgtype.UUID{Valid: false}
}
}
project, err := h.Queries.UpdateProject(r.Context(), params)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update project")
return
}
resp := projectToResponse(project)
h.publish(protocol.EventProjectUpdated, workspaceID, "member", userID, map[string]any{"project": resp})
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) DeleteProject(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
workspaceID := resolveWorkspaceID(r)
if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
ID: parseUUID(id), WorkspaceID: parseUUID(workspaceID),
}); err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
if err := h.Queries.DeleteProject(r.Context(), parseUUID(id)); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete project")
return
}
h.publish(protocol.EventProjectDeleted, workspaceID, "member", userID, map[string]any{"project_id": id})
w.WriteHeader(http.StatusNoContent)
}