Files
multica/server/internal/handler/project.go
T
Naiyuan QingandClaude Fable 5 984a2c2bff MUL-5954: feat: saved issue views V1 (MUL-4796) (#6516)
* feat(issues): server-backed saved issue views API (MUL-4796)

issue_view table (migrations 262-264: table + two CONCURRENTLY partial
indexes) stores named filter definitions: query jsonb is the shared
identity, display jsonb only seeds a user's first open. Scope model is
workspace / my (with scope_variant, forced private) / project (validated
scope_id, deleted with the project in the same app transaction).

issue_view_preference (migration 265) holds each user's view-bar layout
(hidden + order doc) per surface container, last-write-wins.

CRUD + GET/PUT preference endpoints are gated on the saved_issue_views_v1
feature flag (404 while off, key published to the frontend config).
Authorization: private views 404 for everyone but the owner; shared views
editable by owner or workspace admin; updates use expected_revision -> 409.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(core): saved issue views client core (MUL-4796)

- zod schemas + parseWithFallback for views and preferences (definition
  blobs stay loose records interpreted per definition_version), with
  malformed-response tests per the API-compat rules
- issue-views module: list/create/update/delete + preference queries with
  wsId-scoped keys; preference upsert is optimistic (toggle-grade rollback)
- active-view store (zustand client state, URL is the durable carrier) and
  useActiveIssueView with missing-view detection
- baseline.ts: enum-sanitized FilterSnapshot of a view's query for
  value-level locking, delta chips, and baseline-aware resets
- view-store: clearFilterDimension + resetFiltersTo actions; surface store
  seeding routes server blobs through mergeViewStatePersisted

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): three-bar priority icons with a distinct urgent badge

low/medium/high share one three-bar frame (fill level = severity, ghost
bars at 35%); urgent breaks out of the scale as a filled badge with an
exclamation cutout — it is an interrupt, not a fourth bar. All glyphs
share the same 2-14 optical frame.

Board card: PickerWrapper's bare block div gave its inline-flex trigger a
line box (line-height 24px > 20px button), floating icons above the text
midline — the wrappers are flex containers now, and the priority trigger
gets a constant size-5 box so the row's rhythm doesn't depend on the glyph.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(views): saved views UI — view bar, save/edit dialog, locked view mode (MUL-4796)

View bar replaces the built-in tab row on Issues and My Issues: built-in
tabs and saved views render as one flat, per-user ordered row that wraps
instead of overflowing, with a menu for new-view / manage. The manage
dialog does drag reorder (translate-only transform, vertical axis lock),
show/hide (anchor built-in unhidable), and edit/delete for owned views.

Open-view semantics: the view's query is the baseline — its values render
checked-and-disabled in the filter menu, never as chips; chips show only
the user's additions and every reset path (chip x, clear, menu reset,
filtered-empty CTA, board/swimlane hide-column) returns to the baseline,
not to empty. Display seeds once per user (view:<id> surface key) and is
free afterwards. Built-in tab click exits; deleted/revoked views fall back
with a toast; ?view= deep links sync on web via a platform hook.

Save dialog doubles as the edit dialog (PATCH with expected_revision ->
409 toast); manager edits seed from the view definition, in-view edits
from the live panel. Filter chips bar, extracted IssueFilterMenu (virtual
frozen anchor), and the reworked display panel round out the header.

Also: filter menu consistency fixes (closeOnClick, calendar apply,
TableColumnPicker checkbox items), SelectGroup padding, dropdown anchor
passthrough, four-locale i18n.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(views): neutralize workspace tab filter while a saved view is open

The Members/Agents tabs are a coarse assignee-type filter carried in the
surface scope, outside the filter-menu dimensions a view can express. It
kept applying underneath an open view, so the same shared view returned
different rows depending on which tab the user stood on when opening it —
with nothing on screen to explain the difference (tabs dimmed, no chip).

While a view is active the workspace scope now resolves to actorKind
"all": a view's results are defined by the view alone, matching what the
dimmed tabs already imply. Deliberately NOT captured into saved queries —
a view condition must be visible and lockable in the filter menu, and
assignee-type has no menu representation (add the menu dimension first if
that need ever materializes; the loose schema takes the field additively).

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): workspace scope_variant captures and applies the assignee-type tab

A workspace view now saves which built-in tab it was created from
(members/agents, NULL = all) as scope_variant, alongside the existing
my-scope variants. The save dialog shows a scope selector so the
variant can be switched later; scope_type stays immutable. While a
view is open, its own variant drives the workspace scope's
assignee-type axis instead of whichever tab the user stood on.

- migration 266 widens the scope_variant CHECKs (members/agents on
  workspace, NULL = all; my keeps its four variants; project stays NULL)
- UpdateIssueView persists scope_variant; handler validates the variant
  against the view's scope_type on create and update
- issue-surface applies activeView.scope_variant to effectiveScope,
  replacing the blanket tab neutralization
- issues-header seeds the dialog scope from the live tab

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): project pages get a real assignee-type axis and clearer variant copy

The Members/Agents tabs on project pages were decorative: they wrote the
global tab store but the project query never read it. The project scope
now carries actorKind like the workspace scope (assignee_types composes
with project_id server-side), so the tabs filter for real, and project
views join the same optional scope_variant vocabulary — saved from the
tab, switchable in the dialog, applied while the view is open.

- migration 266 (unmerged) widened in place: project allows
  members/agents, NULL = all
- handler treats workspace and project variants identically; new
  TestProjectIssueViewVariant covers persist / normalize / reject
- project scope key gains a per-tab segment (members/agents) so each tab
  keeps its own display state; the unrestricted tab keeps the old key
- save dialog shows the variant selector for project views; the surface
  now provides the dialog's default variant directly (header-side
  injection removed)
- variant labels spell out the meaning (Assigned to members/agents, Any
  assignee, Any relation) and project hints are variant-aware, x4 locales

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-table): project table scope honors assignee_types

The table/rows channel dropped the Members/Agents narrowing on project
pages: the client's project scope never sent assignee_types and the
server's project branch never compiled it, so switching tabs only
changed the highlight while table rows stayed put (the board/list
channel already filtered). Both sides now share the workspace scope's
optional assignee-type narrowing, with a compile test covering the
predicate and the invalid-type rejection.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issues): single assignee-type mapping + per-page tab state

One core helper pair (assigneeTypesForActorKind / actorKindForViewVariant)
replaces the four inlined tab->assignee_types literal mappings across
query-plan, the table spec compiler, and the view-variant application.

The assignee-type tab store becomes page-keyed (issues / project:<id>,
persisted with a v0 migration carrying the old global tab to the Issues
page): switching tabs inside a project no longer drags the Issues page or
other projects along. The save-view dialog's default variant now comes
from the header's own page tab, restoring the /issues default that the
surface-side injection had dropped.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issues): converge all issue surfaces onto the table query channel

The legacy list/grouped query layer duplicated the table channel's scope
semantics behind enable-conditions that were provably always false: list
and status-board ride server status branches, every non-status grouping
and swimlane ride server group branches, and the assignee-board /
per-status load-more / flat-list / flat-export / client-side my:any
3-request merge fallbacks could never execute. Delete the whole layer:

- queries: myIssueListOptions, myIssueAssigneeGroupsOptions,
  issueAssigneeGroupsOptions, issueFlatListOptions/ExportOptions,
  fetchAllFlatPages / fetchAllMyFlatIssues / fetchAllMyFirstPages /
  fetchAllMyAssigneeGroups, compareIssuesForSort (server owns ordering)
- mutations: useLoadMoreByStatus, useLoadMoreByAssigneeGroup
- board/swimlane: the never-taken client-paginated columns, property
  pool loader, hidden-column client counts, legacy footer rows
- surface data hook / controller: dead queries and passthrough fields
- gantt keeps its scheduled-only endpoint and now compiles the
  assignee-type tab into it, restoring the change alongside the cleanup

query-plan shrinks to the scope's non-Table residue: scopeKey, the
gantt filter, and create defaults. issueKeys and cache invalidation
prefixes are untouched; mobile's own data layer is unaffected.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): review fixes — active view's variant wins in save dialog, stable dialog scope, dead mocks

Post-review pass on the convergence refactor:

- The save dialog's default variant now prefers the OPEN view's
  scope_variant over the page tab: saving a copy while an agents-scoped
  view is open must describe the rows on screen, not the tab underneath.
- The dialog scope prop is memoized on primitive projections — a fresh
  object per header render re-armed the dialog's draft-reset effect, so
  any background refetch wiped a half-typed name.
- Tests: persist migration coverage for the page-keyed tab store; gantt
  assignee_types reaches the request and forks the cache key.
- Dead legacy load-more mocks removed from three view test files; stale
  hidden-columns doc comment; unknown hidden-column totals render no
  count instead of a blank; my-relation plan branch is return-exhaustive;
  leftover blank lines.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): keep the grabbing cursor for the whole row drag in manage views

The handle's cursor classes only apply while the pointer hovers the
handle — the vertical-locked drag loses it immediately, and buttons the
pointer crosses flip it to pointer. Mirror the sidebar-resize cursor
contract: an html[data-dnd-dragging] rule forces cursor: grabbing (and
no text selection) document-wide for the drag's duration, toggled by the
DndContext lifecycle with an unmount safety net.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): my views apply their relation at the query layer, not by switching the tab

Opening a my-view used to overwrite the user's relation tab with the
view's variant (VARIANT_TO_TAB), so when an open view vanished
(deleted / access revoked) the fallback landed on the view's tab
instead of where the user actually was. Views are now applied the same
way on all three surfaces: while a view is open, effectiveScope
substitutes the view's variant — assignee-type for workspace/project,
relation for my — and the user's own tab state is never touched.
myRelationForViewVariant joins its sibling mapping in scope.ts with
unit coverage.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): ease the save-view dialog's expand and validation transitions

The display-defaults section snapped open/closed and the name-required
hint popped in. The panel now runs the Base UI height transition
(--collapsible-panel-height + data-starting/ending-style — the
tw-animate collapsible keyframes only know Radix's variable, so the
accordion's animate-* classes cannot be reused here), and the hint eases
in with the house animate-in vocabulary. Scoped to this dialog: the
shared Collapsible primitive stays untouched because chat/transcript
panels grow while open and a pinned panel height would clip them.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): filter chips ease in

Chips popped into the bar and the save-view dialog's embedded list with
no transition. Each chip now enters with the house fade+zoom vocabulary,
keyed per dimension so value edits inside an existing chip don't
re-animate. No exit animation by design.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): drag-reorder the view bar, tab context menu, pin views to sidebar

The bar's tabs (built-ins and saved views alike) now reorder by drag,
writing the same per-user preference document the manage dialog edits —
one list, two editors. Saved-view tabs gain a context menu:

- Edit: visible to everyone, disabled without manage permission (the
  greyed row is the signal); Delete: rendered only for the owner or a
  workspace admin on shared views — mirroring the server's
  canManageIssueView — and routed through the shared confirm dialog now
  extracted from the manage dialog. The manage dialog's own affordances
  follow the same widened rule (admins included).
- Pin to sidebar: pinned_item grows a 'view' type (migration 267; create
  validates read access so foreign private views 404). Sidebar pin rows
  resolve the view's name via the new GET-by-id options, render the
  view-bar icon, activate the view in the store and navigate to its
  owning surface on click. Flag-off clients keep view pins dormant
  instead of auto-unpinning; deleted views follow the existing
  404-auto-unpin pattern.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): review fixes — guard preference wipes, never auto-unpin views

Two data-loss paths from review, plus hardening:

- A drag landing before the views list loaded pruned the preference
  document against an empty list, silently erasing every saved-view
  entry from order and hidden. savePrefs now refuses to write until the
  list has actually loaded (viewsReady threads down from
  useActiveIssueView).
- View pins are exempt from 404-auto-unpin: the detail endpoint also
  404s when the feature flag is switched off server-side, and a client
  that booted with the flag on cannot tell the difference — a flag flip
  would have deleted every view pin permanently. Deleted views' rows
  hide instead.
- POST /api/pins with item_type=view now sits behind the same feature
  gate as every other view endpoint.
- SortableBarTab: drop dnd-kit's aria attributes (they nested a second
  focusable role=button around the tab), move the wasDragged ref into an
  effect, and clear it after a cancelled drag so the next real click
  isn't swallowed.
- Sidebar view pins carry ?view= in their href so a web reload stays on
  the view; scope resolution collapsed to one branch driving both the
  path and the container key.
- Malformed GET /api/issue-views/{id} responses pinned by test as null
  (hide the row), never an error (which alone may unpin).

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): single-row view bar with overflow popover

The bar no longer wraps: tabs that don't fit collapse into a trailing
popover, Linear-style, with two differences — built-ins are first-class
orderable entries, and the whole list (hidden rows dimmed) lives in the
pulled-down panel with drag reorder, per-row actions (edit/delete under
the same permission rule, pin, hide/show) and a new-view entry.

- useSingleRowFit (views/common): hidden mirror row measures natural tab
  widths; ResizeObserver + change-guarded per-commit remeasure computes
  the fitting prefix with trigger reservation. First reusable
  collapse-into-menu primitive in the repo.
- The open view is always visible: when it overflows it takes the
  trigger slot, showing its name + chevron (wider reservation; the
  two-pass promotion settle is monotonic, no oscillation).
- Every tab now shares one max width (max-w-40) and truncates with the
  full name in tooltips/rows.
- ManageViewsDialog retired: the popover carries all of its duties
  (reorder, hide/show incl. built-ins, edit/delete); hiding stays
  impossible for the anchor built-in and still exits a hidden open view.
  DeleteViewConfirm and ViewBarItem move to view-bar-popover.tsx;
  orphaned i18n keys dropped, row action keys added x4 locales.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): restore [layers] menu + manage dialog; 'more' appears only on overflow; fix zero-width bar

The single-row rework overreached: it replaced the existing new/manage
entries and dialog with an all-in-one popover, and the bar's container
collapsed to zero width (content-sized parent + measurement feedback),
rendering an empty header. Back to the intended shape:

- The [layers] menu (new view / manage views) and the manage dialog are
  restored exactly as before; DeleteViewConfirm and ViewBarItem stay
  shared from view-bar-popover.
- The 'more' trigger exists ONLY while tabs overflow, showing just the
  overflowed entries (click to open; per-row edit/delete/pin/hide with
  the same permission rules — those tabs have no rendered surface to
  right-click). The open view still takes the trigger slot when it
  overflows.
- Header view-bar slots gain flex-1 so the fit measures a real width;
  reserve tiers (menu / +more / +promoted) settle monotonically.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): list panel shows ALL tabs with drag reorder; trigger always visible

The pulled-down panel now lists every bar entry (built-ins included) in
bar order with vertical drag reorder — since the bar renders the longest
fitting prefix, reordering in the panel directly decides what sits on
the bar versus tucks away, with a separator marking the current fold.
The trigger no longer appears/disappears with overflow: it is a fixed
part of the bar's right side (still replaced by the open view's own tab
when that view overflows). Row actions (edit/delete/pin/hide) and the
[layers] new/manage entries are unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): kill the post-drop bounce with a synchronous order mirror

The preference mutation is optimistic but its onMutate is async: on drop,
dnd-kit clears the drag transform in the same frame while the reordered
list lands a beat later, so the dropped tab flashed back to its old slot
and then jumped — the manage dialog never bounced because it mirrors the
new order synchronously in the drag handler. Both bar drag and panel
drag now route through applyMove: set a local order mirror in the same
tick, persist, and clear the mirror once the preference data catches up.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): panel row menu is always visible, not hover-revealed

Hover-reveal made the three-dot row menu unreachable on touch devices;
it is a primary affordance in the list panel, so it renders always.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): long view names truncate/wrap everywhere

The manage dialog's row strip is a grid item — min-width:auto let an
80-char name blow past the dialog frame, carrying the row controls with
it. Pin the wrapper (min-w-0 + overflow-hidden) and the rows (min-w-0)
so labels truncate as designed. The delete confirmation and the save
dialog's scope hint interpolate names/titles into prose — break-words
so an unbroken name wraps instead of overflowing. Audited every other
name surface (bar tabs, promoted trigger, panel rows, sidebar pins,
context menus): all already bounded.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issue-views): remove the saved_issue_views_v1 feature flag

The feature ships unconditionally. The flag never reached any released
client, so — unlike the retired flags that live on as always-on compat
keys — the key is removed outright on both sides:

- server: the issueViewsEnabled 404 gate drops from every view endpoint,
  the preference endpoints and the view pin type; the key leaves the
  registry and the frontend-public list; gate tests and the per-test
  enable helper go with it
- frontend: the === true checks, the pre-view-bar fallback tab rows in
  both headers, and useActiveIssueView's enabled parameter are deleted;
  the view bar is now the only rendering
- compat holds by construction: a NEW client against an OLD backend gets
  404s on the list/preference queries → empty views, viewsReady stays
  false (no preference writes), sidebar view pins hide without
  auto-unpinning (comment updated to name this as the reason); an OLD
  client against the NEW backend reads an absent flag as false and keeps
  the feature hidden

Full-suite run also surfaced that the workspace deletion manifest never
classified issue_view / issue_view_preference: both now fall with the
other issue roots in DeleteWorkspaceIssueRoots (no-FK policy: explicit
teardown), and the manifest records the decision.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): fit reserve applies even when every tab fits

The all-fit fast path skipped the reserve — a leftover from when the
overflow trigger only rendered on overflow. With the list trigger and
the [layers] menu now permanent, a row whose tabs exactly filled the
container declared everything fitting and overflow-hidden clipped the
trailing chrome. The reserve is now subtracted unconditionally.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): a freshly created view opens itself

Creating a view now activates it on its surface immediately — the view
you just saved is the one you meant to be looking at. Skipped only when
the create response fails to parse (the list refetch still shows it).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): creating a view no longer bounces the surface to the default tab

Activation raced the list invalidation: the fresh view's id was set
active before the refetched list contained it, the stale list read as
'view deleted', and the missing-view fallback kicked the surface back to
the anchor tab (with an 'unavailable' toast). Two-sided fix: the create
mutation seeds the created view into its scope's list cache before
invalidating, and the missing verdict now requires the list to not be
mid-refetch.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): release-gate fixes — rollback safety, limits, old-client pin compat, revoke sweep

Addresses the pre-ship review blockers:

- CI styles: text-muted-foreground/70 -> text-faint-foreground (solid
  tone rule) in the header hint and the dialog my-note; the chip emoji
  stack's text-[9px] -> text-micro (type-scale rule). apps/web
  text-contrast + type-scale suites pass.
- migration 269 down normalizes workspace/project members/agents
  variants to NULL before restoring the old constraints — verified on a
  scratch DB with real variant data (up -> write -> down).
- view pins are withheld from the legacy /api/pins contract unless the
  client opts in (?include=view): old Desktop builds classified any
  non-issue pin as a project pin and permanently auto-unpinned it on
  404. New clients opt in; regression test pins one and asserts the
  legacy list never leaks it.
- write hardening: MaxBytesReader (128KB) on view create/update and
  preference PUT; isJSONObject rejects JSON null (was a DB-CHECK 500);
  per-owner view quota (100/workspace); list query LIMIT 200. Boundary
  tests for null blob, oversized body, and quota.
- member revoke now sweeps the departed member's PRIVATE views and their
  view-bar preferences in the same transaction (shared views stay, same
  rule as private quick actions) — the migration comments promised this;
  test covers private-gone/shared-stays/prefs-gone.
- sidebar comment no longer claims project ?view= reload support.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): deleting a view sweeps its sidebar pins on every path

View pins never auto-unpin client-side (old-backend safety), so a pin
whose view is gone was invisible in the UI and unremovable forever —
and could leave an empty-looking Pinned group. All three deletion paths
now sweep matching pinned_item rows atomically in the same statement
via CTEs: direct view delete, project deletion (its scoped views), and
member revoke (their private views). Regression tests cover each path.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-10 11:55:05 +08:00

935 lines
30 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"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"
"github.com/multica-ai/multica/server/internal/util"
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"`
// StartDate / DueDate are calendar days ("YYYY-MM-DD"), no time-of-day or
// timezone — same contract as issue.start_date / issue.due_date.
StartDate *string `json:"start_date"`
DueDate *string `json:"due_date"`
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),
StartDate: dateToPtr(p.StartDate),
DueDate: dateToPtr(p.DueDate),
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"`
StartDate *string `json:"start_date"`
DueDate *string `json:"due_date"`
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"`
StartDate *string `json:"start_date"`
DueDate *string `json:"due_date"`
}
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
}
// start_date / due_date are optional calendar days; an absent or empty
// value leaves the column NULL. Mirrors CreateIssue's date handling.
var startDate pgtype.Date
if req.StartDate != nil && *req.StartDate != "" {
d, err := util.ParseCalendarDate(*req.StartDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
return
}
startDate = d
}
var dueDate pgtype.Date
if req.DueDate != nil && *req.DueDate != "" {
d, err := util.ParseCalendarDate(*req.DueDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid due_date format, expected YYYY-MM-DD")
return
}
dueDate = d
}
// 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,
StartDate: startDate,
DueDate: dueDate,
}
// 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,
StartDate: prevProject.StartDate,
DueDate: prevProject.DueDate,
}
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}
}
}
// Dates follow the issue contract: a present key with an empty/null value
// clears the date; an absent key leaves the prior value untouched.
if _, ok := rawFields["start_date"]; ok {
if req.StartDate != nil && *req.StartDate != "" {
d, err := util.ParseCalendarDate(*req.StartDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_date format, expected YYYY-MM-DD")
return
}
params.StartDate = d
} else {
params.StartDate = pgtype.Date{Valid: false} // explicit null = clear date
}
}
if _, ok := rawFields["due_date"]; ok {
if req.DueDate != nil && *req.DueDate != "" {
d, err := util.ParseCalendarDate(*req.DueDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid due_date format, expected YYYY-MM-DD")
return
}
params.DueDate = d
} else {
params.DueDate = pgtype.Date{Valid: false} // explicit null = clear date
}
}
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)
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)
if _, err := qtx.LockProjectForDelete(r.Context(), db.LockProjectForDeleteParams{
ID: project.ID,
WorkspaceID: project.WorkspaceID,
}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "project not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to lock project")
return
}
if err := qtx.ClearChatSessionProjectByProject(r.Context(), db.ClearChatSessionProjectByProjectParams{
ProjectID: project.ID,
WorkspaceID: project.WorkspaceID,
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to clear project chat context")
return
}
// Project-scoped saved views live on the project page; once the project
// is gone they are unreachable, so they go in the same transaction.
if err := qtx.DeleteIssueViewsByProjectScope(r.Context(), db.DeleteIssueViewsByProjectScopeParams{
WorkspaceID: project.WorkspaceID,
ScopeID: project.ID,
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete project views")
return
}
if err := qtx.DeleteProject(r.Context(), db.DeleteProjectParams{
ID: project.ID,
WorkspaceID: project.WorkspaceID,
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete project")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to commit project delete")
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"
// Cancelled projects are abandoned work. Project search has no other status
// ranking, and the command palette renders projects above issues, so
// without this a cancelled project can be the very first row of the result
// list. Demote ahead of rankExpr, with the same direct-hit exception as
// issue search (see buildSearchQuery): an exact title means the user is
// targeting that one project.
cancelledRank := fmt.Sprintf(
"CASE WHEN p.status = 'cancelled' AND LOWER(p.title) <> %s THEN 1 ELSE 0 END",
phraseParam,
)
// --- 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.start_date, p.due_date,
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, %s, p.updated_at DESC
LIMIT %s OFFSET %s`,
matchSourceExpr,
wsParam,
whereClause,
cancelledRank,
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.StartDate,
&row.project.DueDate,
&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,
})
}