mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* fix(projects): return 400 (not 500) for invalid project status/priority CreateProject/UpdateProject passed an unvalidated status/priority straight to the INSERT, so an unknown value (e.g. --status active) tripped the table's CHECK constraint and surfaced as a blanket 500 'failed to create project' with no server-side log to diagnose it (#3925). Pre-validate both enums against the column CHECK lists and return a 400 with the allowed values. Back it with isCheckViolation -> 400 for any other constrained column, and log the underlying error on genuine 500s so transient DB failures are diagnosable. MUL-3153 Co-authored-by: multica-agent <github@multica.ai> * fix(cli): validate project --status in create/update project create and project update forwarded --status to the server without checking it, while project status already validated. Share a single validateProjectStatus helper across all three so a typo fails fast with the valid list instead of a server round-trip. MUL-3153 Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
813 lines
25 KiB
Go
813 lines
25 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/logger"
|
|
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"`
|
|
Priority string `json:"priority"`
|
|
LeadType *string `json:"lead_type"`
|
|
LeadID *string `json:"lead_id"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
IssueCount int64 `json:"issue_count"`
|
|
DoneCount int64 `json:"done_count"`
|
|
// ResourceCount is a breadcrumb pointing at the sub-collection at
|
|
// /api/projects/{id}/resources. Resources themselves stay out of this
|
|
// payload to keep parent metadata and child collections separate; clients
|
|
// that need the list call ListProjectResources directly.
|
|
ResourceCount int64 `json:"resource_count"`
|
|
}
|
|
|
|
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,
|
|
Priority: p.Priority,
|
|
LeadType: textToPtr(p.LeadType),
|
|
LeadID: uuidToPtr(p.LeadID),
|
|
CreatedAt: timestampToString(p.CreatedAt),
|
|
UpdatedAt: timestampToString(p.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
func (h *Handler) loadProjectIssueStats(ctx context.Context, projectID pgtype.UUID) (int64, int64) {
|
|
stats, err := h.Queries.GetProjectIssueStats(ctx, []pgtype.UUID{projectID})
|
|
if err != nil || len(stats) == 0 {
|
|
return 0, 0
|
|
}
|
|
return stats[0].TotalCount, stats[0].DoneCount
|
|
}
|
|
|
|
func (h *Handler) loadProjectResourceCount(ctx context.Context, projectID pgtype.UUID) int64 {
|
|
rows, err := h.Queries.GetProjectResourceCounts(ctx, []pgtype.UUID{projectID})
|
|
if err != nil || len(rows) == 0 {
|
|
return 0
|
|
}
|
|
return rows[0].ResourceCount
|
|
}
|
|
|
|
type CreateProjectRequest struct {
|
|
Title string `json:"title"`
|
|
Description *string `json:"description"`
|
|
Icon *string `json:"icon"`
|
|
Status string `json:"status"`
|
|
Priority string `json:"priority"`
|
|
LeadType *string `json:"lead_type"`
|
|
LeadID *string `json:"lead_id"`
|
|
Resources []CreateProjectResourceRequestPayload `json:"resources,omitempty"`
|
|
}
|
|
|
|
// CreateProjectResourceRequestPayload mirrors CreateProjectResourceRequest but
|
|
// is embedded inside the project create payload. Kept as a separate type so a
|
|
// future change to the standalone request can't silently break this surface.
|
|
type CreateProjectResourceRequestPayload struct {
|
|
ResourceType string `json:"resource_type"`
|
|
ResourceRef json.RawMessage `json:"resource_ref"`
|
|
Label *string `json:"label"`
|
|
Position *int32 `json:"position"`
|
|
}
|
|
|
|
type UpdateProjectRequest struct {
|
|
Title *string `json:"title"`
|
|
Description *string `json:"description"`
|
|
Icon *string `json:"icon"`
|
|
Status *string `json:"status"`
|
|
Priority *string `json:"priority"`
|
|
LeadType *string `json:"lead_type"`
|
|
LeadID *string `json:"lead_id"`
|
|
}
|
|
|
|
func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var statusFilter pgtype.Text
|
|
if s := r.URL.Query().Get("status"); s != "" {
|
|
statusFilter = pgtype.Text{String: s, Valid: true}
|
|
}
|
|
var priorityFilter pgtype.Text
|
|
if p := r.URL.Query().Get("priority"); p != "" {
|
|
priorityFilter = pgtype.Text{String: p, Valid: true}
|
|
}
|
|
projects, err := h.Queries.ListProjects(r.Context(), db.ListProjectsParams{
|
|
WorkspaceID: wsUUID,
|
|
Status: statusFilter,
|
|
Priority: priorityFilter,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list projects")
|
|
return
|
|
}
|
|
|
|
// Batch-fetch issue stats and resource counts for all projects
|
|
statsMap := make(map[string]db.GetProjectIssueStatsRow)
|
|
resourceCountMap := make(map[string]int64)
|
|
if len(projects) > 0 {
|
|
projectIDs := make([]pgtype.UUID, len(projects))
|
|
for i, p := range projects {
|
|
projectIDs[i] = p.ID
|
|
}
|
|
stats, err := h.Queries.GetProjectIssueStats(r.Context(), projectIDs)
|
|
if err == nil {
|
|
for _, s := range stats {
|
|
statsMap[uuidToString(s.ProjectID)] = s
|
|
}
|
|
}
|
|
counts, err := h.Queries.GetProjectResourceCounts(r.Context(), projectIDs)
|
|
if err == nil {
|
|
for _, c := range counts {
|
|
resourceCountMap[uuidToString(c.ProjectID)] = c.ResourceCount
|
|
}
|
|
}
|
|
}
|
|
|
|
resp := make([]ProjectResponse, len(projects))
|
|
for i, p := range projects {
|
|
resp[i] = projectToResponse(p)
|
|
if s, ok := statsMap[resp[i].ID]; ok {
|
|
resp[i].IssueCount = s.TotalCount
|
|
resp[i].DoneCount = s.DoneCount
|
|
}
|
|
resp[i].ResourceCount = resourceCountMap[resp[i].ID]
|
|
}
|
|
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 := h.resolveWorkspaceID(r)
|
|
idUUID, ok := parseUUIDOrBadRequest(w, id, "project id")
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
|
|
ID: idUUID, WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "project not found")
|
|
return
|
|
}
|
|
resp := projectToResponse(project)
|
|
resp.IssueCount, resp.DoneCount = h.loadProjectIssueStats(r.Context(), project.ID)
|
|
resp.ResourceCount = h.loadProjectResourceCount(r.Context(), project.ID)
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// validProjectStatuses / validProjectPriorities mirror the CHECK constraints on
|
|
// the project table (migrations 034, 035). CreateProject / UpdateProject
|
|
// pre-validate against these so an unknown enum value returns a clean 400 with
|
|
// the allowed list instead of surfacing the DB CHECK violation as a 500 — the
|
|
// exact mismatch reported in #3925 (`--status active`).
|
|
var validProjectStatuses = []string{"planned", "in_progress", "paused", "completed", "cancelled"}
|
|
var validProjectPriorities = []string{"urgent", "high", "medium", "low", "none"}
|
|
|
|
// validateProjectEnum writes a 400 and returns false when value is not in
|
|
// allowed; the caller returns immediately on false.
|
|
func validateProjectEnum(w http.ResponseWriter, field, value string, allowed []string) bool {
|
|
for _, a := range allowed {
|
|
if value == a {
|
|
return true
|
|
}
|
|
}
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid %s %q; valid values: %s", field, value, strings.Join(allowed, ", ")))
|
|
return false
|
|
}
|
|
|
|
// writeProjectWriteError maps a failed project INSERT/UPDATE to an HTTP
|
|
// response. A CHECK constraint violation is a client error (400) — pre-validation
|
|
// already covers status/priority, so this backstops any other constrained column
|
|
// (e.g. lead_type). Anything else is a genuine server fault: log the underlying
|
|
// error so transient DB failures are diagnosable (#3925 had no server-side
|
|
// signal) and return 500.
|
|
func (h *Handler) writeProjectWriteError(w http.ResponseWriter, r *http.Request, err error, action string) {
|
|
if isCheckViolation(err) {
|
|
writeError(w, http.StatusBadRequest, "project "+action+" rejected: a field value failed a database constraint")
|
|
return
|
|
}
|
|
slog.Error("project "+action+" failed", append(logger.RequestAttrs(r), "error", err)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to "+action+" 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 := h.resolveWorkspaceID(r)
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
status := req.Status
|
|
if status == "" {
|
|
status = "planned"
|
|
}
|
|
if !validateProjectEnum(w, "status", status, validProjectStatuses) {
|
|
return
|
|
}
|
|
priority := req.Priority
|
|
if priority == "" {
|
|
priority = "none"
|
|
}
|
|
if !validateProjectEnum(w, "priority", priority, validProjectPriorities) {
|
|
return
|
|
}
|
|
var leadType pgtype.Text
|
|
var leadID pgtype.UUID
|
|
if req.LeadType != nil {
|
|
leadType = pgtype.Text{String: *req.LeadType, Valid: true}
|
|
}
|
|
if req.LeadID != nil {
|
|
id, ok := parseUUIDOrBadRequest(w, *req.LeadID, "lead_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
leadID = id
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Pre-validate every resource payload before opening a transaction so an
|
|
// invalid ref produces a clean 400 with no DB work. For local_directory we
|
|
// also enforce one row per daemon_id within the batch — the daemon-side
|
|
// resolver picks the first match by daemon_id, so two rows on the same
|
|
// daemon would silently route the agent into whichever sorts first.
|
|
// The standalone POST/PUT paths run the same check via
|
|
// findLocalDirectoryConflict; this loop just covers the bundled-create
|
|
// surface, where there is no existing row to compare against yet.
|
|
normalizedRefs := make([]json.RawMessage, len(req.Resources))
|
|
localDirSeen := map[string]int{}
|
|
for i, res := range req.Resources {
|
|
res.ResourceType = strings.TrimSpace(res.ResourceType)
|
|
if res.ResourceType == "" {
|
|
writeError(w, http.StatusBadRequest, "resources[].resource_type is required")
|
|
return
|
|
}
|
|
ref, err := validateAndNormalizeResourceRef(res.ResourceType, res.ResourceRef)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "resources["+strconv.Itoa(i)+"]: "+err.Error())
|
|
return
|
|
}
|
|
normalizedRefs[i] = ref
|
|
if res.ResourceType == "local_directory" {
|
|
var ld localDirectoryRef
|
|
if err := json.Unmarshal(ref, &ld); err != nil {
|
|
writeError(w, http.StatusBadRequest, "resources["+strconv.Itoa(i)+"]: "+err.Error())
|
|
return
|
|
}
|
|
if prev, ok := localDirSeen[ld.DaemonID]; ok {
|
|
writeError(w, http.StatusBadRequest, "resources["+strconv.Itoa(i)+"]: duplicate local_directory for daemon (already at index "+strconv.Itoa(prev)+"); each daemon may attach at most one local_directory per project")
|
|
return
|
|
}
|
|
localDirSeen[ld.DaemonID] = i
|
|
}
|
|
}
|
|
|
|
createParams := db.CreateProjectParams{
|
|
WorkspaceID: wsUUID,
|
|
Title: req.Title,
|
|
Description: ptrToText(req.Description),
|
|
Icon: ptrToText(req.Icon),
|
|
Status: status,
|
|
LeadType: leadType,
|
|
LeadID: leadID,
|
|
Priority: priority,
|
|
}
|
|
|
|
// Without resources, keep the simple non-tx path.
|
|
if len(req.Resources) == 0 {
|
|
project, err := h.Queries.CreateProject(r.Context(), createParams)
|
|
if err != nil {
|
|
h.writeProjectWriteError(w, r, err, "create")
|
|
return
|
|
}
|
|
resp := projectToResponse(project)
|
|
h.publish(protocol.EventProjectCreated, workspaceID, "member", userID, map[string]any{"project": resp})
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
return
|
|
}
|
|
|
|
// Transactional path: project + all resources are atomic.
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to start transaction")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
project, err := qtx.CreateProject(r.Context(), createParams)
|
|
if err != nil {
|
|
h.writeProjectWriteError(w, r, err, "create")
|
|
return
|
|
}
|
|
|
|
creator, _ := h.parseUserUUIDOrZero(userID)
|
|
resourceRows := make([]db.ProjectResource, 0, len(req.Resources))
|
|
for i, res := range req.Resources {
|
|
var label pgtype.Text
|
|
if res.Label != nil && strings.TrimSpace(*res.Label) != "" {
|
|
label = pgtype.Text{String: strings.TrimSpace(*res.Label), Valid: true}
|
|
}
|
|
var position int32 = int32(i)
|
|
if res.Position != nil {
|
|
position = *res.Position
|
|
}
|
|
row, err := qtx.CreateProjectResource(r.Context(), db.CreateProjectResourceParams{
|
|
ProjectID: project.ID,
|
|
WorkspaceID: project.WorkspaceID,
|
|
ResourceType: res.ResourceType,
|
|
ResourceRef: normalizedRefs[i],
|
|
Label: label,
|
|
Position: position,
|
|
CreatedBy: creator,
|
|
})
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
writeError(w, http.StatusConflict, "resources["+strconv.Itoa(i)+"]: this resource is already attached")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to attach resource at index "+strconv.Itoa(i))
|
|
return
|
|
}
|
|
resourceRows = append(resourceRows, row)
|
|
}
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to commit project create")
|
|
return
|
|
}
|
|
|
|
resourceResp := make([]ProjectResourceResponse, len(resourceRows))
|
|
for i, row := range resourceRows {
|
|
resourceResp[i] = projectResourceToResponse(row)
|
|
}
|
|
resp := projectToResponse(project)
|
|
resp.ResourceCount = int64(len(resourceResp))
|
|
h.publish(protocol.EventProjectCreated, workspaceID, "member", userID, map[string]any{"project": resp})
|
|
for _, rr := range resourceResp {
|
|
h.publish(protocol.EventProjectResourceCreated, workspaceID, "member", userID, map[string]any{
|
|
"resource": rr,
|
|
"project_id": resp.ID,
|
|
})
|
|
}
|
|
// One-shot create echo: the parent ProjectResponse fields plus the just-
|
|
// created resources. This is a transient creation echo, not a contract for
|
|
// reads — GET /projects/{id} stays metadata-only with resource_count.
|
|
writeJSON(w, http.StatusCreated, struct {
|
|
ProjectResponse
|
|
Resources []ProjectResourceResponse `json:"resources"`
|
|
}{
|
|
ProjectResponse: resp,
|
|
Resources: resourceResp,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
idUUID, ok := parseUUIDOrBadRequest(w, id, "project id")
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
prevProject, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
|
|
ID: idUUID, WorkspaceID: wsUUID,
|
|
})
|
|
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 {
|
|
if !validateProjectEnum(w, "status", *req.Status, validProjectStatuses) {
|
|
return
|
|
}
|
|
params.Status = pgtype.Text{String: *req.Status, Valid: true}
|
|
}
|
|
if req.Priority != nil {
|
|
if !validateProjectEnum(w, "priority", *req.Priority, validProjectPriorities) {
|
|
return
|
|
}
|
|
params.Priority = pgtype.Text{String: *req.Priority, 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 {
|
|
leadUUID, ok := parseUUIDOrBadRequest(w, *req.LeadID, "lead_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
params.LeadID = leadUUID
|
|
} else {
|
|
params.LeadID = pgtype.UUID{Valid: false}
|
|
}
|
|
}
|
|
project, err := h.Queries.UpdateProject(r.Context(), params)
|
|
if err != nil {
|
|
h.writeProjectWriteError(w, r, err, "update")
|
|
return
|
|
}
|
|
resp := projectToResponse(project)
|
|
resp.IssueCount, resp.DoneCount = h.loadProjectIssueStats(r.Context(), project.ID)
|
|
resp.ResourceCount = h.loadProjectResourceCount(r.Context(), project.ID)
|
|
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 := h.resolveWorkspaceID(r)
|
|
idUUID, ok := parseUUIDOrBadRequest(w, id, "project id")
|
|
if !ok {
|
|
return
|
|
}
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
project, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
|
|
ID: idUUID, WorkspaceID: wsUUID,
|
|
})
|
|
if 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(), db.DeleteProjectParams{
|
|
ID: project.ID,
|
|
WorkspaceID: project.WorkspaceID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete project")
|
|
return
|
|
}
|
|
h.publish(protocol.EventProjectDeleted, workspaceID, "member", userID, map[string]any{"project_id": uuidToString(project.ID)})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// SearchProjectResponse extends ProjectResponse with search metadata.
|
|
type SearchProjectResponse struct {
|
|
ProjectResponse
|
|
MatchSource string `json:"match_source"`
|
|
MatchedSnippet *string `json:"matched_snippet,omitempty"`
|
|
}
|
|
|
|
// buildProjectSearchQuery builds a dynamic SQL query for project search.
|
|
func buildProjectSearchQuery(phrase string, terms []string, includeClosed bool) (string, []any) {
|
|
phrase = strings.ToLower(phrase)
|
|
for i, t := range terms {
|
|
terms[i] = strings.ToLower(t)
|
|
}
|
|
|
|
argIdx := 1
|
|
args := []any{}
|
|
nextArg := func(val any) string {
|
|
args = append(args, val)
|
|
s := fmt.Sprintf("$%d", argIdx)
|
|
argIdx++
|
|
return s
|
|
}
|
|
|
|
escapedPhrase := escapeLike(phrase)
|
|
phraseParam := nextArg(escapedPhrase)
|
|
phraseContains := "'%' || " + phraseParam + " || '%'"
|
|
phraseStartsWith := phraseParam + " || '%'"
|
|
|
|
wsParam := nextArg(nil) // workspace_id placeholder
|
|
|
|
var termParams []string
|
|
if len(terms) > 1 {
|
|
for _, t := range terms {
|
|
et := escapeLike(t)
|
|
termParams = append(termParams, nextArg(et))
|
|
}
|
|
}
|
|
|
|
// --- WHERE clause ---
|
|
var whereParts []string
|
|
|
|
// Full phrase match: title or description
|
|
phraseMatch := fmt.Sprintf(
|
|
"(LOWER(p.title) LIKE %s OR LOWER(COALESCE(p.description, '')) LIKE %s)",
|
|
phraseContains, phraseContains,
|
|
)
|
|
whereParts = append(whereParts, phraseMatch)
|
|
|
|
// Multi-word AND match
|
|
if len(termParams) > 1 {
|
|
var termConditions []string
|
|
for _, tp := range termParams {
|
|
tc := "'%' || " + tp + " || '%'"
|
|
termConditions = append(termConditions, fmt.Sprintf(
|
|
"(LOWER(p.title) LIKE %s OR LOWER(COALESCE(p.description, '')) LIKE %s)",
|
|
tc, tc,
|
|
))
|
|
}
|
|
whereParts = append(whereParts, "("+strings.Join(termConditions, " AND ")+")")
|
|
}
|
|
|
|
whereClause := "(" + strings.Join(whereParts, " OR ") + ")"
|
|
|
|
if !includeClosed {
|
|
whereClause += " AND p.status NOT IN ('completed', 'cancelled')"
|
|
}
|
|
|
|
// --- ORDER BY ranking ---
|
|
var rankCases []string
|
|
|
|
// Tier 0: Exact title match
|
|
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(p.title) = %s THEN 0", phraseParam))
|
|
|
|
// Tier 1: Title starts with phrase
|
|
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(p.title) LIKE %s THEN 1", phraseStartsWith))
|
|
|
|
// Tier 2: Title contains phrase
|
|
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(p.title) LIKE %s THEN 2", phraseContains))
|
|
|
|
// Tier 3: Title matches all words (multi-word only)
|
|
if len(termParams) > 1 {
|
|
var titleTerms []string
|
|
for _, tp := range termParams {
|
|
titleTerms = append(titleTerms, fmt.Sprintf("LOWER(p.title) LIKE '%s' || %s || '%s'", "%", tp, "%"))
|
|
}
|
|
rankCases = append(rankCases, fmt.Sprintf("WHEN (%s) THEN 3", strings.Join(titleTerms, " AND ")))
|
|
}
|
|
|
|
// Tier 4: Description contains phrase
|
|
rankCases = append(rankCases, fmt.Sprintf("WHEN LOWER(COALESCE(p.description, '')) LIKE %s THEN 4", phraseContains))
|
|
|
|
rankExpr := "CASE " + strings.Join(rankCases, " ") + " ELSE 5 END"
|
|
|
|
// --- match_source expression ---
|
|
matchSourceExpr := fmt.Sprintf(`CASE
|
|
WHEN LOWER(p.title) LIKE %s THEN 'title'
|
|
ELSE 'description'
|
|
END`, phraseContains)
|
|
|
|
if len(termParams) > 1 {
|
|
var titleTerms []string
|
|
for _, tp := range termParams {
|
|
titleTerms = append(titleTerms, fmt.Sprintf("LOWER(p.title) LIKE '%s' || %s || '%s'", "%", tp, "%"))
|
|
}
|
|
matchSourceExpr = fmt.Sprintf(`CASE
|
|
WHEN LOWER(p.title) LIKE %s THEN 'title'
|
|
WHEN (%s) THEN 'title'
|
|
ELSE 'description'
|
|
END`,
|
|
phraseContains, strings.Join(titleTerms, " AND "),
|
|
)
|
|
}
|
|
|
|
limitParam := nextArg(nil)
|
|
offsetParam := nextArg(nil)
|
|
|
|
query := fmt.Sprintf(`SELECT p.id, p.workspace_id, p.title, p.description, p.icon,
|
|
p.status, p.priority, p.lead_type, p.lead_id,
|
|
p.created_at, p.updated_at,
|
|
COUNT(*) OVER() AS total_count,
|
|
%s AS match_source
|
|
FROM project p
|
|
WHERE p.workspace_id = %s AND %s
|
|
ORDER BY %s, p.updated_at DESC
|
|
LIMIT %s OFFSET %s`,
|
|
matchSourceExpr,
|
|
wsParam,
|
|
whereClause,
|
|
rankExpr,
|
|
limitParam,
|
|
offsetParam,
|
|
)
|
|
|
|
return query, args
|
|
}
|
|
|
|
func (h *Handler) SearchProjects(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
|
|
q := r.URL.Query().Get("q")
|
|
if q == "" {
|
|
writeError(w, http.StatusBadRequest, "q parameter is required")
|
|
return
|
|
}
|
|
|
|
limit := 20
|
|
offset := 0
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
|
limit = v
|
|
}
|
|
}
|
|
if limit > 50 {
|
|
limit = 50
|
|
}
|
|
if o := r.URL.Query().Get("offset"); o != "" {
|
|
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
|
|
offset = v
|
|
}
|
|
}
|
|
|
|
includeClosed := r.URL.Query().Get("include_closed") == "true"
|
|
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
terms := splitSearchTerms(q)
|
|
|
|
sqlQuery, args := buildProjectSearchQuery(q, terms, includeClosed)
|
|
args[1] = wsUUID
|
|
args[len(args)-2] = limit
|
|
args[len(args)-1] = offset
|
|
|
|
rows, err := h.DB.Query(ctx, sqlQuery, args...)
|
|
if err != nil {
|
|
slog.Warn("search projects failed", "error", err, "workspace_id", workspaceID, "query", q)
|
|
writeError(w, http.StatusInternalServerError, "failed to search projects")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type projectSearchRow struct {
|
|
project db.Project
|
|
totalCount int64
|
|
matchSource string
|
|
}
|
|
|
|
var results []projectSearchRow
|
|
for rows.Next() {
|
|
var row projectSearchRow
|
|
if err := rows.Scan(
|
|
&row.project.ID,
|
|
&row.project.WorkspaceID,
|
|
&row.project.Title,
|
|
&row.project.Description,
|
|
&row.project.Icon,
|
|
&row.project.Status,
|
|
&row.project.Priority,
|
|
&row.project.LeadType,
|
|
&row.project.LeadID,
|
|
&row.project.CreatedAt,
|
|
&row.project.UpdatedAt,
|
|
&row.totalCount,
|
|
&row.matchSource,
|
|
); err != nil {
|
|
slog.Warn("search projects scan failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to search projects")
|
|
return
|
|
}
|
|
results = append(results, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.Warn("search projects rows error", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to search projects")
|
|
return
|
|
}
|
|
|
|
var total int64
|
|
if len(results) > 0 {
|
|
total = results[0].totalCount
|
|
}
|
|
|
|
// Batch-fetch issue stats and resource counts
|
|
statsMap := make(map[string]db.GetProjectIssueStatsRow)
|
|
resourceCountMap := make(map[string]int64)
|
|
if len(results) > 0 {
|
|
projectIDs := make([]pgtype.UUID, len(results))
|
|
for i, r := range results {
|
|
projectIDs[i] = r.project.ID
|
|
}
|
|
stats, err := h.Queries.GetProjectIssueStats(ctx, projectIDs)
|
|
if err == nil {
|
|
for _, s := range stats {
|
|
statsMap[uuidToString(s.ProjectID)] = s
|
|
}
|
|
}
|
|
counts, err := h.Queries.GetProjectResourceCounts(ctx, projectIDs)
|
|
if err == nil {
|
|
for _, c := range counts {
|
|
resourceCountMap[uuidToString(c.ProjectID)] = c.ResourceCount
|
|
}
|
|
}
|
|
}
|
|
|
|
resp := make([]SearchProjectResponse, len(results))
|
|
for i, row := range results {
|
|
pr := projectToResponse(row.project)
|
|
if s, ok := statsMap[pr.ID]; ok {
|
|
pr.IssueCount = s.TotalCount
|
|
pr.DoneCount = s.DoneCount
|
|
}
|
|
pr.ResourceCount = resourceCountMap[pr.ID]
|
|
spr := SearchProjectResponse{
|
|
ProjectResponse: pr,
|
|
MatchSource: row.matchSource,
|
|
}
|
|
if row.matchSource == "description" {
|
|
desc := ""
|
|
if row.project.Description.Valid {
|
|
desc = row.project.Description.String
|
|
}
|
|
if desc != "" {
|
|
snippet := extractSnippet(desc, q)
|
|
spr.MatchedSnippet = &snippet
|
|
}
|
|
}
|
|
resp[i] = spr
|
|
}
|
|
|
|
w.Header().Set("X-Total-Count", strconv.FormatInt(total, 10))
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"projects": resp,
|
|
"total": total,
|
|
})
|
|
}
|