Files
multica/server/internal/handler/project.go
beast 31d942d010 MUL-3438: fix(projects): require admin for project deletion (#4327)
* fix(projects): require admin for project deletion

* test(projects): clean up orphaned member rows in delete-permission helper

The schema uses no foreign keys or cascades, so deleting the test user
left its member row behind in the shared test workspace, polluting later
tests in the package. Delete the member row before the user in both the
pre-seed cleanup and t.Cleanup.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-21 23:54:58 +08:00

814 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
}
requester, ok := h.requireWorkspaceRole(w, r, uuidToString(project.WorkspaceID), "project not found", "owner", "admin")
if !ok {
return
}
userID := uuidToString(requester.UserID)
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,
})
}