mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 13:18:56 +02:00
* fix(search): add pg_trgm index fallback + statement_timeout guard (MUL-4059) Root cause of the "search freezes with no response" symptom reported in MUL-4059: the search handler runs LOWER(col) LIKE '%pattern%' queries that expect a pg_bigm GIN index (migrations 032, 033, 036), but every migration wraps the CREATE EXTENSION + CREATE INDEX in a DO/EXCEPTION handler that silently skips when pg_bigm is unavailable. The bundled self-host / dev / CI Postgres image (pgvector/pgvector:pg17) does not ship pg_bigm, so on every self-hosted deployment the migrations no-op and no GIN indexes get built. Every /api/issues/search + /api/projects/search request then falls back to a Seq Scan on `issue` + correlated Seq Scans on `comment` — verified with EXPLAIN on the local dev DB, which has zero title/description/comment search indexes before this change. Two independent guardrails are added, either of which alone would have prevented the reported hang: 1) Migration 134 installs pg_trgm (ships in all standard Postgres + pgvector images) and builds GIN indexes with gin_trgm_ops on `LOWER(title)`, `LOWER(COALESCE(description, ''))`, and `LOWER(content)`. The expression signatures match the search handler's WHERE clauses exactly, so the planner picks the index without further changes. The pg_bigm indexes from 036 are left intact — deployments on AWS RDS with pg_bigm 1.2 keep the CJK-friendly bigram path; deployments without it get the trigram fallback. Verified against a local 25k-row fixture: the description LIKE hits `Bitmap Index Scan on idx_issue_description_trgm` in 0.5 ms. 2) runSearchQuery wraps both search handlers in a short-lived read-only transaction with SET LOCAL statement_timeout = 3 s. In the pathological case where indexes are still missing or the query plan is bad, callers see a fast 503 with a descriptive error instead of a stalled request. Verified against a live Postgres: a deliberate pg_sleep(2) with the test override at 200 ms is cut off in 230 ms with SQLSTATE 57014, as asserted by TestRunSearchQuery_StatementTimeoutFires. Non-goals: this change does not remove the pg_bigm code path, does not change the SQL the handler builds, and does not change the API response shape. It is the minimum diff to unblock production while preserving the CJK-search advantage that pg_bigm provides where it is available. Co-authored-by: multica-agent <github@multica.ai> * fix(search): scope comment subqueries to workspace to unblock prd hang (MUL-4059) Follow-up correction after PRD investigation: pg_bigm IS installed on prd `multica-prod` and all five bigm indexes exist in the correct `LOWER(...) gin_bigm_ops` form. The initial "missing index" hypothesis was wrong; migration 134 (pg_trgm fallback) still helps self-host but does not touch the production hang path. Actual prd EXPLAIN (workspace with 60k issues, keyword "search"): Index Scan using idx_issue_workspace on issue i Rows Removed by Filter: 59123 SubPlan 2 Bitmap Heap Scan on comment c Rows Removed by Index Recheck: 1928275 Heap Blocks: exact=48297 lossy=164696 Bitmap Index Scan on idx_comment_content_bigm rows=536761 Execution Time: 32345.002 ms Root cause: the correlated `EXISTS` over `comment` gets rewritten by the planner into a *hashed* subplan. Without a workspace_id filter in the subquery, that hashed set covers every comment in every workspace matching the LIKE — 536k rows for "search" — which spills work_mem into a lossy bitmap and rechecks 1.9M rows. Two-part fix: 1. Query rewrite. buildSearchQuery now emits `c.workspace_id = $wsParam` inside every comment subquery (WHERE phrase match, WHERE multi-term match, tier 7 rank, tier 8 rank, and the matched_comment_content COALESCE). The same $4 parameter is reused so Postgres treats it as a compile-time constant and pushes it into the hashed subplan's key, collapsing the set to this workspace's comments. 2. Supporting index (migration 135). New `idx_comment_workspace ON comment (workspace_id)`. Without it, the pushed-down filter still triggers a Seq Scan on `comment` because comment has no btree on workspace_id (only the FK constraint and composite (issue_id, ...) indexes). Locally verified against a repro that mirrors prd (5k issues in the target workspace, 100k comments in a sibling workspace all containing "search"): the plan drops from 60 ms (hashed global scan, no support index) to 3 ms (subplan uses idx_comment_workspace). Prd extrapolation from the same shape: 32.3 s → tens of milliseconds. Regression test TestBuildSearchQuery_CommentSubqueryWorkspaceScope asserts every `FROM comment c` in the generated SQL is followed by a `c.workspace_id = $4` filter, so a future refactor can't silently regress the plan back to the global-hash pathology. The statement_timeout guard from the earlier commit in this branch is kept — it still bounds the worst case if any future query shape regresses. Co-authored-by: multica-agent <github@multica.ai> * fix(search): address PR review — unwrap 135 + add project trigram indexes (MUL-4059) Both must-fix items from GPT-Boy's review: 1. Migration 135 unwrapped. The previous version buried `CREATE INDEX idx_comment_workspace` inside `DO $$ ... EXCEPTION WHEN OTHERS $$` — exactly the anti-pattern that caused MUL-4059 in the first place. `idx_comment_workspace` is not a CJK-bonus fallback; it is the critical support that makes the query rewrite land on an Index Scan instead of a Seq Scan. A silent failure (lock timeout, disk full, permission denied, schema drift) MUST abort the migration and fail deployment, not slip through as green. The unwrapped `CREATE INDEX IF NOT EXISTS` now propagates real errors to the migration runner, which aborts and does NOT record the version as applied. IF NOT EXISTS keeps idempotency for the operator-precreated case (`CREATE INDEX CONCURRENTLY ...` before running migrations on large prd tables). 2. Migration 134 now covers project search too. SearchProjects reads `LOWER(project.title)` and `LOWER(COALESCE(project.description, ''))`, and the pg_bigm equivalents in migration 039 silently no-op on pg_bigm-less images just like 032/033/036. Without the trigram fallback, project searches on self-host would still Seq Scan and hit the 3 s statement_timeout guard as a 503 — technically bounded but not actually fixed. Added `idx_project_title_trgm` and `idx_project_description_trgm`; the down migration drops them too. Also: fixed the search.go comment that said callers get a "standard 500" — they get a 503 with SQLSTATE-57014 mapping; the comment now matches reality. Verified: build clean, vet clean, existing search / timeout tests still green. Migration 135 dry-run (dropping the index, re-applying the unwrapped SQL under `ON_ERROR_STOP=1`) creates the index cleanly; a deliberate `CREATE INDEX` on a non-existent column now aborts psql with exit 3, confirming the migration runner would fail loudly on any real error. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
818 lines
25 KiB
Go
818 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"
|
|
"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
|
|
|
|
type projectSearchRow struct {
|
|
project db.Project
|
|
totalCount int64
|
|
matchSource string
|
|
}
|
|
|
|
var results []projectSearchRow
|
|
err := runSearchQuery(ctx, h.TxStarter, sqlQuery, args, func(rows pgx.Rows) error {
|
|
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 {
|
|
return fmt.Errorf("scan: %w", err)
|
|
}
|
|
results = append(results, row)
|
|
}
|
|
return rows.Err()
|
|
})
|
|
if err != nil {
|
|
// Statement-timeout surfaces as SQLSTATE 57014 — same
|
|
// fail-fast contract as SearchIssues (see runSearchQuery).
|
|
if isSearchStatementTimeout(err) {
|
|
slog.Warn("search projects timed out",
|
|
"workspace_id", workspaceID,
|
|
"query", q,
|
|
"timeout", searchStatementTimeout)
|
|
writeError(w, http.StatusServiceUnavailable, "search timed out; please refine your query or try again")
|
|
return
|
|
}
|
|
slog.Warn("search projects failed", "error", err, "workspace_id", workspaceID, "query", q)
|
|
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,
|
|
})
|
|
}
|