Files
multica/server/internal/handler/property.go
Jiayuan Zhang b85bb71a58 feat: custom issue properties — typed workspace-defined fields with list-surface support (MUL-4463) (#5335)
* feat(server): custom issue properties — definitions, typed values, CLI (MUL-4463)

Workspace-level property definitions (issue_property table; 7 types:
text/number/select/multi_select/date/checkbox/url) plus a typed value bag
on each issue (issue.properties JSONB keyed by definition UUID, mirroring
the metadata machinery: single-key atomic writes, 16KB cap, GIN index).

- Definitions: owner/admin only; agent actors rejected (agents propose,
  humans confirm). 20 active per workspace, 50 options per select,
  reserved built-in names blocked, archive instead of delete.
- Values: any member or agent; per-type validation with self-correcting
  error messages that enumerate legal option ids.
- API: /api/properties CRUD + PUT/DELETE /api/issues/{id}/properties/{propertyId};
  issue responses always emit the properties bag.
- CLI: multica property list/get/create/update/archive/unarchive and
  multica issue property list/set/unset with name→id translation.
- Events: property:created/updated, issue_properties:changed.

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

* feat(web): custom properties settings tab + issue sidebar editors (MUL-4463)

- Settings → Properties: definition management mirroring the Labels tab
  (list with type badges/option chips/usage counts, create/edit dialog
  with option editor, archive/restore, 20-cap indicator). Admin-gated;
  members see a read-only catalog.
- Issue detail sidebar: custom properties join the built-in optional
  props' progressive disclosure — set values render as rows with
  type-appropriate editors (select/multi-select pickers, calendar,
  yes/no, inline input for text/number/url), unset ones live in the
  same '+ Add property' menu behind a separator. Archived definitions
  render read-only until cleared.
- Core: property types, zod schemas (lenient type strings for forward
  compat), api client methods, React Query hooks with optimistic
  single-key value writes, ws-updaters + realtime wiring for
  property:created/updated and issue_properties:changed.
- Locales: en/zh-Hans/ja/ko strings; Issue fixtures gain properties: {}.

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

* fix(properties): address MUL-4463 review round 1 — mobile CI, option guard, mutation safety, schema tolerance

- mobile: EMPTY_ISSUE_FALLBACK gains the required properties field (mobile
  typecheck was the red CI check).
- server: PATCH /api/properties/{id} rejects config updates that remove
  select options still referenced by issues (409 with a per-option usage
  census via jsonb ?); renames keep ids and pass. Integration test included.
- core: property value mutations are serialized per workspace via mutation
  scope, snapshot the bag from detail OR list caches (board surfaces have no
  detail cache — the old path overwrote whole bags with one key), roll back
  to the snapshot or invalidate on error, and the last settled mutation does
  an authoritative detail+catalog invalidate (usage counts reconcile).
- schemas: unknown-shaped property values (future server types) are dropped
  per-entry in a preprocess step instead of failing the whole IssueSchema
  and blanking lists through parseWithFallback; test updated to lock the
  tolerant behavior.
- realtime: reconnect invalidation covers the property catalog; every
  issue_properties:changed event also refreshes catalog usage counts.
- ui: number editor accepts decimals (step=any); settings usage count
  pluralizes (issue/issues) with CJK-safe plural keys.

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

* fix(migrations): renumber issue properties to 179 and build the GIN index concurrently

main's migration sequence advanced twice under this PR (167 collision, then
an upstream renumber wave that claimed 178), so issue properties now sits at
179 — verified against main's current tip by the prefix-uniqueness lint.

The properties GIN index moves to its own single-statement migration (180)
using CREATE INDEX CONCURRENTLY — a plain CREATE INDEX on the hot issue
table would block writes for the duration of the build. Mirrors the
119_user_created_at_index pattern; full-chain dry-run on a fresh database
passes through 180.

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

* feat(web): custom-property list surfaces — filter, cards, sort, board grouping (MUL-4463 M2)

Brings custom properties to the issue list surfaces on top of the M1
definitions/values core:

- Filter: per-definition sections in the Filter dropdown (select /
  multi_select options with color dots and counts; checkbox as Yes/No
  pseudo-options). OR within a definition, AND across definitions;
  client-side in applyIssueFilters, mirrored into filterAssigneeGroups
  for the assignee-grouped board. Included in active-filter count and
  Clear all.
- Cards: per-property Display toggles (cardPropertyIds) render value
  chips on board cards and list rows via CustomPropertyValueDisplay.
- Sort: SortField gains property:<id> for number/date definitions.
  Server keeps position order (fixed sort enum); the surface controller
  re-sorts client-side, swimlane/gantt reuse the same comparator.
  Date-only strings compare lexically; missing values sort last.
- Board grouping: IssueGrouping gains property:<id> for select
  definitions — one column per option (definition order) plus a
  trailing No-value column, option-colored headings. Drag-drop moves
  position via UpdateIssue and applies the value through
  useSetIssueProperty/useUnsetIssueProperty (properties are not part
  of UpdateIssueRequest). Stale persisted property groupings fall back
  to status columns.

View-store: propertyFilters + cardPropertyIds persisted via the
partialize allowlist; clearFilters resets property filters; new fields
deep-merge cleanly into pre-existing persisted snapshots.

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

* fix(properties): address MUL-4463 review round 2 — desc sort, option bucketing, archived-state reconciliation

- sort: direction now applies to value comparison only; issues without a
  value sort last in BOTH directions (the whole-array reverse flipped them
  to the front on desc). Test covers the desc+missing case.
- board: values referencing an option removed from the definition bucket
  into the No-value column instead of vanishing (unmatched column ids
  dropped the issue entirely). Defense-in-depth behind the new server-side
  in-use guard; drag-utils test locks both behaviors.
- controller: persisted propertyFilters keyed by archived/deleted
  definitions are stripped before reaching the filter predicates, and a
  persisted property sort on a non-active definition degrades to manual
  order — previously both kept silently applying while the header claimed
  otherwise. The filter badge counts only active-definition filters.

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

* feat(properties): server-side property filtering and sorting on list endpoints

Property filter/sort now execute in the database, so results are correct
across the full issue set — not just the loaded 50-per-status window
(closes MUL-4493 item 1's filter/sort half; requested on MUL-4463).

- New `properties` query param on ListIssues and ListGroupedIssues:
  JSON {definitionId: [values]} compiled to an AND-of-ORs containment
  check (double NOT EXISTS over jsonb_array_elements). One value expands
  to every storage shape it could match — string (select), array element
  (multi_select), boolean (checkbox) — so the handler stays type-agnostic.
  Guarded at 20 definitions / 50 values.
- `sort=property:<definitionId>` resolves the definition and orders by a
  typed expression (numeric CASE cast for number, NULLIF text for
  date/text/url); missing values sort last in both directions. Malformed
  ids 400; unknown/archived definitions degrade to position order instead
  of breaking stale clients.
- Frontend: the property filter and property sort ride the IssueSortParam
  window bag, so every surface (workspace + my-issues variants), query
  key, and per-status load-more page carries them automatically. The
  client-side re-sort layer is gone; applyIssueFilters keeps its property
  predicate as an optimistic-update backstop.
- Regression test seeds 55 issues and proves a match at position 55 is
  returned by a filtered 50-row page, plus sort order/missing-last,
  AND-across-definitions, and the 400/fallback sort paths.

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

* fix(properties): address review round 3 — cache reconcile, merged-scope order, GIN-indexable filter, pool loader

- Cache reconciliation: property value writes (mutation settle + WS event)
  now invalidate every issue window whose server-side shape depends on
  property values — queries filtered by `properties` or sorted by
  `property:<id>` (detected via query-key predicate), covering flat lists,
  assignee groups, and my-issues variants. Windows without property params
  keep the cheap in-place patch. Fixes stale ordering/membership/counts
  under staleTime:Infinity.
- My Issues "All" scope: merged assigned/created/involves results are
  re-sorted with a comparator mirroring the server ORDER BY semantics
  (including property sorts and missing-last, created_at DESC tiebreak) in
  both the flat and assignee-grouped merge paths — relation concatenation
  no longer overrides the user's sort.
- Filter predicate rebuilt as plain bind-parameter containment ORs
  (AND across definitions): EXPLAIN now shows BitmapOr over
  idx_issue_properties_gin (the correlated jsonb_array_elements form
  defeated the index). Alternatives capped at 256 bind params.
- Property-grouped board gains a pool loader strip: one sentinel per
  status that still has server rows, keeping every issue reachable until
  per-column pagination lands (MUL-4493).
- Windowing regression test hardened: explicit positions + an assertion
  that the unfiltered first page excludes the target (the old fixture tied
  at position 0 and the created_at DESC tiebreak put the target on page
  one, proving nothing).
- Rollback safety: /api/properties 404 (old server) degrades to an empty
  catalog instead of a query error, which also keeps property params from
  ever being sent to pre-property servers; migration 179's CHECK
  constraints switch to NOT VALID + VALIDATE so the exclusive lock is
  instantaneous.

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

* fix(properties): harden concurrency and cache coordination from clean-room review

Backend (MUL-4762 F1/F4/F5):
- withPropertyLock: pg_advisory_xact_lock helper; definition create/update
  and value writes now serialize config-vs-value and cap-vs-insert races
  (workspace-level 'props:' lock + per-definition 'prop:' lock, ordered).
- propertySortExpr degrades archived definitions to position sort.

Frontend (F2/F3/F6):
- onIssuePropertiesChanged invalidates plain assignee-group caches too.
- Property value mutations cancel list refetches in onMutate and roll back
  only the touched key against the current bag (concurrent WS writes to
  other keys survive a failed write).
- useUpdateIssue reconcile drops the stale properties bag from the server
  snapshot; the property pipeline owns that field.
- Surface controller passes persisted property filters/sorts through
  until the catalog query settles (cold cache no longer strips them).

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

* fix(properties): open_only branch honors the properties filter

ListOpenIssues takes the parsed AND-of-ORs containment groups as a single
jsonb properties_filter param and unrolls them with a static double
NOT EXISTS; previously the open_only path parsed the properties param and
silently dropped it (clean-room review F7a).

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

* fix(properties): toast on failed board drag to a property column

Property-column drags rolled the card back silently on failure; mirror
the status/assignee drag path (use-issue-surface-actions) so the
snap-back is explained (clean-room review F3, drag half).

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 12:00:28 +08:00

981 lines
34 KiB
Go

package handler
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"sort"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"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"
)
// Custom issue properties (MUL-4463): workspace-level typed property
// definitions plus a per-issue value bag.
//
// Contract highlights (decided on MUL-4463):
// - Definitions are managed by human owner/admin members only. Agent actors
// are rejected even when the runtime owner has the role — otherwise field
// sprawl becomes something agents can mass-produce.
// - Values are writable by every member and agent; validation is typed per
// definition, and errors enumerate legal values so agents can self-correct.
// - Value writes are single-key atomic (mirror of issue metadata): two
// agents writing different properties never clobber each other.
// - Definitions archive instead of delete; archived definitions reject new
// values but keep existing ones resolvable.
const (
maxActivePropertiesPerWorkspace = 20
maxPropertySelectOptions = 50
maxPropertyNameLen = 32
maxPropertyDescriptionLen = 500
maxPropertyTextValueLen = 2000
maxPropertyURLValueLen = 2048
)
var validPropertyTypes = []string{"text", "number", "select", "multi_select", "date", "checkbox", "url"}
// errClientRejected marks lock-closure failures already translated to an
// HTTP status by the caller-side fail() capture.
var errClientRejected = errors.New("client rejected")
// reservedPropertyNames blocks definitions that would collide with built-in
// issue fields ("system properties"). Comparison happens on the normalized
// form: lowercased, spaces collapsed to underscores — so "Due Date", "due
// date", and "due_date" are all rejected.
var reservedPropertyNames = map[string]struct{}{
"status": {}, "priority": {}, "assignee": {}, "project": {}, "parent": {},
"stage": {}, "label": {}, "labels": {}, "start_date": {}, "due_date": {},
"title": {}, "description": {}, "creator": {}, "created_at": {}, "updated_at": {},
"metadata": {}, "properties": {},
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type PropertyOption struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
}
type PropertyConfig struct {
Options []PropertyOption `json:"options,omitempty"`
}
type PropertyResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Config PropertyConfig `json:"config"`
Position float64 `json:"position"`
Archived bool `json:"archived"`
ArchivedAt *string `json:"archived_at"`
UsageCount int64 `json:"usage_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func parsePropertyConfig(raw []byte) PropertyConfig {
var cfg PropertyConfig
if len(raw) == 0 {
return cfg
}
if err := json.Unmarshal(raw, &cfg); err != nil {
return PropertyConfig{}
}
return cfg
}
func propertyToResponse(p db.IssueProperty, usageCount int64) PropertyResponse {
resp := PropertyResponse{
ID: uuidToString(p.ID),
WorkspaceID: uuidToString(p.WorkspaceID),
Name: p.Name,
Type: p.Type,
Description: p.Description,
Config: parsePropertyConfig(p.Config),
Position: p.Position,
Archived: p.ArchivedAt.Valid,
UsageCount: usageCount,
CreatedAt: timestampToString(p.CreatedAt),
UpdatedAt: timestampToString(p.UpdatedAt),
}
if p.ArchivedAt.Valid {
s := timestampToString(p.ArchivedAt)
resp.ArchivedAt = &s
}
return resp
}
func propertyListRowToResponse(row db.ListIssuePropertiesRow) PropertyResponse {
return propertyToResponse(db.IssueProperty{
ID: row.ID,
WorkspaceID: row.WorkspaceID,
Name: row.Name,
Type: row.Type,
Description: row.Description,
Config: row.Config,
Position: row.Position,
ArchivedAt: row.ArchivedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, row.UsageCount)
}
type CreatePropertyRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Config *PropertyConfig `json:"config"`
}
type UpdatePropertyRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Config *PropertyConfig `json:"config"`
Archived *bool `json:"archived"`
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
func normalizePropertyName(name string) string {
return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(name)), " ", "_")
}
func validatePropertyName(raw string) (string, error) {
for _, r := range raw {
if unicode.IsControl(r) {
return "", errors.New("name cannot contain tabs, newlines, or control characters")
}
}
name := strings.TrimSpace(raw)
if name == "" {
return "", errors.New("name is required")
}
if utf8.RuneCountInString(name) > maxPropertyNameLen {
return "", fmt.Errorf("name must be %d characters or fewer", maxPropertyNameLen)
}
if _, reserved := reservedPropertyNames[normalizePropertyName(name)]; reserved {
return "", fmt.Errorf("%q is reserved for a built-in issue field", name)
}
return name, nil
}
func validatePropertyType(t string) error {
for _, v := range validPropertyTypes {
if t == v {
return nil
}
}
return fmt.Errorf("invalid type %q; valid types: %s", t, strings.Join(validPropertyTypes, ", "))
}
func propertyTypeHasOptions(t string) bool {
return t == "select" || t == "multi_select"
}
// validatePropertyConfig canonicalizes the config for storage. Select-type
// properties require 1..50 options; each option gets a stable server-assigned
// UUID if the caller didn't provide one (values reference option IDs, so
// option renames never touch issue rows). Non-select types must not carry
// options and are stored as {}.
func validatePropertyConfig(propType string, cfg *PropertyConfig) ([]byte, error) {
if !propertyTypeHasOptions(propType) {
if cfg != nil && len(cfg.Options) > 0 {
return nil, fmt.Errorf("type %q does not accept options", propType)
}
return []byte(`{}`), nil
}
if cfg == nil || len(cfg.Options) == 0 {
return nil, errors.New("select properties require at least one option")
}
if len(cfg.Options) > maxPropertySelectOptions {
return nil, fmt.Errorf("a property cannot have more than %d options", maxPropertySelectOptions)
}
seenIDs := make(map[string]struct{}, len(cfg.Options))
seenNames := make(map[string]struct{}, len(cfg.Options))
out := PropertyConfig{Options: make([]PropertyOption, 0, len(cfg.Options))}
for _, opt := range cfg.Options {
name, err := validateLabelName(opt.Name)
if err != nil {
return nil, fmt.Errorf("option %w", err)
}
lower := strings.ToLower(name)
if _, dup := seenNames[lower]; dup {
return nil, fmt.Errorf("duplicate option name %q", name)
}
seenNames[lower] = struct{}{}
color, err := normalizeColor(opt.Color)
if err != nil {
return nil, fmt.Errorf("option %q: %w", name, err)
}
id := strings.TrimSpace(opt.ID)
if id == "" {
id = uuid.NewString()
} else if _, err := uuid.Parse(id); err != nil {
return nil, fmt.Errorf("option %q: id must be a UUID", name)
}
if _, dup := seenIDs[id]; dup {
return nil, fmt.Errorf("duplicate option id %q", id)
}
seenIDs[id] = struct{}{}
out.Options = append(out.Options, PropertyOption{ID: id, Name: name, Color: color})
}
return json.Marshal(out)
}
func propertyOptionIDs(cfg PropertyConfig) map[string]int {
ids := make(map[string]int, len(cfg.Options))
for i, opt := range cfg.Options {
ids[opt.ID] = i
}
return ids
}
func selectOptionsHint(cfg PropertyConfig) string {
parts := make([]string, len(cfg.Options))
for i, opt := range cfg.Options {
parts[i] = fmt.Sprintf("%s (%s)", opt.ID, opt.Name)
}
return strings.Join(parts, ", ")
}
// validatePropertyValue checks a raw JSON value against the definition's type
// and returns the canonical JSON to store. Error strings enumerate the legal
// values where possible — agents consume these directly to self-correct.
func validatePropertyValue(def db.IssueProperty, raw json.RawMessage) ([]byte, error) {
if len(raw) == 0 {
return nil, errors.New("value is required")
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return nil, fmt.Errorf("value must be valid JSON: %w", err)
}
if v == nil {
return nil, errors.New("value cannot be null (use DELETE to unset a property)")
}
cfg := parsePropertyConfig(def.Config)
switch def.Type {
case "text":
s, ok := v.(string)
if !ok {
return nil, errors.New("value must be a string")
}
if strings.TrimSpace(s) == "" {
return nil, errors.New("value cannot be empty (use DELETE to unset a property)")
}
if utf8.RuneCountInString(s) > maxPropertyTextValueLen {
return nil, fmt.Errorf("value must be %d characters or fewer", maxPropertyTextValueLen)
}
return json.Marshal(sanitizeNullBytes(s))
case "url":
s, ok := v.(string)
if !ok {
return nil, errors.New("value must be a URL string")
}
s = strings.TrimSpace(s)
if len(s) > maxPropertyURLValueLen {
return nil, fmt.Errorf("value must be %d characters or fewer", maxPropertyURLValueLen)
}
u, err := url.Parse(s)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, errors.New("value must be an http(s) URL")
}
return json.Marshal(s)
case "number":
if _, ok := v.(float64); !ok {
return nil, errors.New("value must be a number")
}
return json.Marshal(v)
case "checkbox":
if _, ok := v.(bool); !ok {
return nil, errors.New("value must be true or false")
}
return json.Marshal(v)
case "date":
s, ok := v.(string)
if !ok {
return nil, errors.New("value must be a date string in YYYY-MM-DD format")
}
if _, err := time.Parse("2006-01-02", s); err != nil {
return nil, errors.New("value must be a date string in YYYY-MM-DD format")
}
return json.Marshal(s)
case "select":
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("value must be one of the option ids: %s", selectOptionsHint(cfg))
}
if _, exists := propertyOptionIDs(cfg)[s]; !exists {
return nil, fmt.Errorf("value must be one of the option ids: %s", selectOptionsHint(cfg))
}
return json.Marshal(s)
case "multi_select":
items, ok := v.([]any)
if !ok || len(items) == 0 {
return nil, fmt.Errorf("value must be a non-empty array of option ids: %s", selectOptionsHint(cfg))
}
order := propertyOptionIDs(cfg)
seen := make(map[string]struct{}, len(items))
ids := make([]string, 0, len(items))
for _, item := range items {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("value must be a non-empty array of option ids: %s", selectOptionsHint(cfg))
}
if _, exists := order[s]; !exists {
return nil, fmt.Errorf("unknown option id %q; valid option ids: %s", s, selectOptionsHint(cfg))
}
if _, dup := seen[s]; dup {
continue
}
seen[s] = struct{}{}
ids = append(ids, s)
}
// Canonicalize to config order so equal selections serialize equally
// (stable @> containment filtering and change detection).
sort.SliceStable(ids, func(a, b int) bool { return order[ids[a]] < order[ids[b]] })
return json.Marshal(ids)
default:
return nil, fmt.Errorf("unsupported property type %q", def.Type)
}
}
// removedOptionIDs returns option ids present in the stored config but
// absent from the incoming replacement.
func removedOptionIDs(existingConfig, nextConfig []byte) []string {
next := propertyOptionIDs(parsePropertyConfig(nextConfig))
var removed []string
for _, opt := range parsePropertyConfig(existingConfig).Options {
if _, kept := next[opt.ID]; !kept {
removed = append(removed, opt.ID)
}
}
return removed
}
// describeOptionsInUse renders the 409 body for in-use option removal, e.g.
// `cannot remove options still in use: "Critical" (3 issues); clear or
// change those values first`.
func describeOptionsInUse(existingConfig []byte, rows []db.CountIssuesUsingPropertyOptionsRow) string {
names := make(map[string]string)
for _, opt := range parsePropertyConfig(existingConfig).Options {
names[opt.ID] = opt.Name
}
parts := make([]string, len(rows))
for i, row := range rows {
name := names[row.OptionID]
if name == "" {
name = row.OptionID
}
parts[i] = fmt.Sprintf("%q (%d issues)", name, row.UsageCount)
}
sort.Strings(parts)
return "cannot remove options still in use: " + strings.Join(parts, ", ") + "; clear or change those values first"
}
// parseIssueProperties mirrors parseIssueMetadata for the properties bag.
func parseIssueProperties(raw []byte) map[string]any {
if len(raw) == 0 {
return map[string]any{}
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil || out == nil {
return map[string]any{}
}
return out
}
// ---------------------------------------------------------------------------
// Definition handlers
// ---------------------------------------------------------------------------
// requirePropertyAdmin gates definition writes: human owner/admin members
// only. Agent actors are rejected before the role check — an agent inherits
// its runtime owner's credentials, and without this check an admin's agent
// could mass-create definitions (MUL-4463 decision: agents propose via
// comments, humans confirm).
func (h *Handler) requirePropertyAdmin(w http.ResponseWriter, r *http.Request) (workspaceID, userID string, ok bool) {
workspaceID = h.resolveWorkspaceID(r)
userID, ok = requireUserID(w, r)
if !ok {
return "", "", false
}
if actorType, _ := h.resolveActor(r, userID, workspaceID); actorType == "agent" {
writeError(w, http.StatusForbidden, "agents cannot manage property definitions")
return "", "", false
}
if _, roleOK := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !roleOK {
return "", "", false
}
return workspaceID, userID, true
}
func (h *Handler) ListProperties(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
includeArchived := r.URL.Query().Get("include_archived") == "true"
rows, err := h.Queries.ListIssueProperties(r.Context(), db.ListIssuePropertiesParams{
WorkspaceID: wsUUID,
IncludeArchived: includeArchived,
})
if err != nil {
slog.Warn("ListIssueProperties failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to list properties")
return
}
resp := make([]PropertyResponse, len(rows))
for i, row := range rows {
resp[i] = propertyListRowToResponse(row)
}
writeJSON(w, http.StatusOK, map[string]any{"properties": resp, "total": len(resp)})
}
func (h *Handler) GetProperty(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "property id")
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
property, err := h.Queries.GetIssueProperty(r.Context(), db.GetIssuePropertyParams{ID: idUUID, WorkspaceID: wsUUID})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "property not found")
return
}
slog.Warn("GetIssueProperty failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to get property")
return
}
writeJSON(w, http.StatusOK, propertyToResponse(property, 0))
}
func (h *Handler) CreateProperty(w http.ResponseWriter, r *http.Request) {
workspaceID, userID, ok := h.requirePropertyAdmin(w, r)
if !ok {
return
}
var req CreatePropertyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
name, err := validatePropertyName(req.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := validatePropertyType(req.Type); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if utf8.RuneCountInString(req.Description) > maxPropertyDescriptionLen {
writeError(w, http.StatusBadRequest, fmt.Sprintf("description must be %d characters or fewer", maxPropertyDescriptionLen))
return
}
configJSON, err := validatePropertyConfig(req.Type, req.Config)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
var property db.IssueProperty
var capErr error
err = h.withPropertyLock(r, []string{"props:" + workspaceID}, func(q *db.Queries) error {
active, err := q.CountActiveIssueProperties(r.Context(), wsUUID)
if err != nil {
return err
}
if active >= maxActivePropertiesPerWorkspace {
capErr = fmt.Errorf("a workspace cannot have more than %d active properties; archive unused ones first", maxActivePropertiesPerWorkspace)
return capErr
}
property, err = q.CreateIssueProperty(r.Context(), db.CreateIssuePropertyParams{
WorkspaceID: wsUUID,
Name: name,
Type: req.Type,
Description: sanitizeNullBytes(strings.TrimSpace(req.Description)),
Config: configJSON,
})
return err
})
if capErr != nil {
writeError(w, http.StatusBadRequest, capErr.Error())
return
}
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "a property with that name already exists")
return
}
slog.Warn("CreateIssueProperty failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to create property")
return
}
resp := propertyToResponse(property, 0)
h.publish(protocol.EventPropertyCreated, workspaceID, "member", userID, map[string]any{"property": resp})
writeJSON(w, http.StatusCreated, resp)
}
func (h *Handler) UpdateProperty(w http.ResponseWriter, r *http.Request) {
workspaceID, userID, ok := h.requirePropertyAdmin(w, r)
if !ok {
return
}
idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "property id")
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
var req UpdatePropertyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// The whole read → validate → census → write flow runs under advisory
// locks (workspace, then property): a concurrent value write on this
// property serializes behind the property lock, so the in-use census
// cannot race a value that would reference a removed option (TOCTOU,
// clean-room review F1); the workspace lock makes the unarchive cap
// check atomic against creates (F5).
var property db.IssueProperty
var httpStatus int
var httpMsg string
fail := func(status int, msg string) error {
httpStatus, httpMsg = status, msg
return errClientRejected
}
err := h.withPropertyLock(r, []string{"props:" + workspaceID, "prop:" + uuidToString(idUUID)}, func(q *db.Queries) error {
existing, err := q.GetIssueProperty(r.Context(), db.GetIssuePropertyParams{ID: idUUID, WorkspaceID: wsUUID})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fail(http.StatusNotFound, "property not found")
}
return err
}
params := db.UpdateIssuePropertyParams{ID: idUUID, WorkspaceID: wsUUID}
if req.Name != nil {
name, err := validatePropertyName(*req.Name)
if err != nil {
return fail(http.StatusBadRequest, err.Error())
}
params.Name = pgtype.Text{String: name, Valid: true}
}
if req.Description != nil {
if utf8.RuneCountInString(*req.Description) > maxPropertyDescriptionLen {
return fail(http.StatusBadRequest, fmt.Sprintf("description must be %d characters or fewer", maxPropertyDescriptionLen))
}
params.Description = pgtype.Text{String: sanitizeNullBytes(strings.TrimSpace(*req.Description)), Valid: true}
}
if req.Config != nil {
configJSON, err := validatePropertyConfig(existing.Type, req.Config)
if err != nil {
return fail(http.StatusBadRequest, err.Error())
}
if removed := removedOptionIDs(existing.Config, configJSON); len(removed) > 0 {
rows, err := q.CountIssuesUsingPropertyOptions(r.Context(), db.CountIssuesUsingPropertyOptionsParams{
OptionIds: removed,
WorkspaceID: wsUUID,
PropertyKey: uuidToString(existing.ID),
})
if err != nil {
return err
}
if len(rows) > 0 {
return fail(http.StatusConflict, describeOptionsInUse(existing.Config, rows))
}
}
params.Config = configJSON
}
if req.Archived != nil {
params.ArchivedSet = true
if *req.Archived {
params.ArchivedAt = pgtype.Timestamptz{Time: time.Now().UTC(), Valid: true}
} else if existing.ArchivedAt.Valid {
active, err := q.CountActiveIssueProperties(r.Context(), wsUUID)
if err != nil {
return err
}
if active >= maxActivePropertiesPerWorkspace {
return fail(http.StatusBadRequest, fmt.Sprintf("a workspace cannot have more than %d active properties; archive unused ones first", maxActivePropertiesPerWorkspace))
}
}
}
property, err = q.UpdateIssueProperty(r.Context(), params)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fail(http.StatusNotFound, "property not found")
}
if isUniqueViolation(err) {
return fail(http.StatusConflict, "a property with that name already exists")
}
return err
}
return nil
})
if err != nil {
if errors.Is(err, errClientRejected) {
writeError(w, httpStatus, httpMsg)
return
}
slog.Warn("UpdateProperty failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to update property")
return
}
resp := propertyToResponse(property, 0)
h.publish(protocol.EventPropertyUpdated, workspaceID, "member", userID, map[string]any{"property": resp})
writeJSON(w, http.StatusOK, resp)
}
// ---------------------------------------------------------------------------
// Issue value handlers
// ---------------------------------------------------------------------------
type SetIssuePropertyRequest struct {
Value json.RawMessage `json:"value"`
}
func (h *Handler) SetIssueProperty(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
propertyID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "propertyId"), "property id")
if !ok {
return
}
var req SetIssuePropertyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
// Validation and write share the property advisory lock with definition
// updates: the value written is guaranteed to reference the definition
// state that a concurrent config edit's usage census will see (TOCTOU,
// clean-room review F1).
var updated db.Issue
var httpStatus int
var httpMsg string
fail := func(status int, msg string) error {
httpStatus, httpMsg = status, msg
return errClientRejected
}
err := h.withPropertyLock(r, []string{"prop:" + uuidToString(propertyID)}, func(q *db.Queries) error {
def, err := q.GetIssueProperty(r.Context(), db.GetIssuePropertyParams{ID: propertyID, WorkspaceID: issue.WorkspaceID})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fail(http.StatusNotFound, "property not found")
}
return err
}
if def.ArchivedAt.Valid {
return fail(http.StatusBadRequest, fmt.Sprintf("property %q is archived and cannot receive new values", def.Name))
}
value, err := validatePropertyValue(def, req.Value)
if err != nil {
return fail(http.StatusBadRequest, err.Error())
}
updated, err = q.SetIssuePropertyValue(r.Context(), db.SetIssuePropertyValueParams{
ID: issue.ID,
WorkspaceID: issue.WorkspaceID,
Key: uuidToString(def.ID),
Value: value,
})
if err != nil {
if isCheckViolation(err) {
return fail(http.StatusBadRequest, "issue properties exceed the 16KB size limit")
}
return err
}
return nil
})
if err != nil {
if errors.Is(err, errClientRejected) {
writeError(w, httpStatus, httpMsg)
return
}
slog.Warn("SetIssueProperty failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...)
writeError(w, http.StatusInternalServerError, "failed to set property")
return
}
workspaceID := uuidToString(updated.WorkspaceID)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
properties := parseIssueProperties(updated.Properties)
h.publish(protocol.EventIssuePropertiesChanged, workspaceID, actorType, actorID, map[string]any{
"issue_id": uuidToString(updated.ID),
"properties": properties,
})
writeJSON(w, http.StatusOK, map[string]any{"properties": properties})
}
func (h *Handler) DeleteIssueProperty(w http.ResponseWriter, r *http.Request) {
issueID := chi.URLParam(r, "id")
propertyID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "propertyId"), "property id")
if !ok {
return
}
issue, ok := h.loadIssueForUser(w, r, issueID)
if !ok {
return
}
userID, ok := requireUserID(w, r)
if !ok {
return
}
// Deleting a value is allowed even for archived definitions — cleanup
// must never be blocked. Unknown property ids only need to belong to the
// workspace; `properties - key` is a no-op when the key is absent.
if _, err := h.Queries.GetIssueProperty(r.Context(), db.GetIssuePropertyParams{ID: propertyID, WorkspaceID: issue.WorkspaceID}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "property not found")
return
}
slog.Warn("GetIssueProperty in DeleteIssueProperty failed", append(logger.RequestAttrs(r), "error", err)...)
writeError(w, http.StatusInternalServerError, "failed to unset property")
return
}
updated, err := h.Queries.DeleteIssuePropertyValue(r.Context(), db.DeleteIssuePropertyValueParams{
ID: issue.ID,
WorkspaceID: issue.WorkspaceID,
Key: uuidToString(propertyID),
})
if err != nil {
slog.Warn("DeleteIssuePropertyValue failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...)
writeError(w, http.StatusInternalServerError, "failed to unset property")
return
}
workspaceID := uuidToString(updated.WorkspaceID)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
properties := parseIssueProperties(updated.Properties)
h.publish(protocol.EventIssuePropertiesChanged, workspaceID, actorType, actorID, map[string]any{
"issue_id": uuidToString(updated.ID),
"properties": properties,
})
writeJSON(w, http.StatusOK, map[string]any{"properties": properties})
}
// withPropertyLock runs fn inside a transaction holding the advisory lock
// for the given key, serializing definition mutations against value writes
// (TOCTOU: a config update's usage census and a concurrent value write could
// otherwise interleave into a permanently orphaned option reference) and
// definition creates/unarchives against each other (the 20-active cap and
// MAX(position)+1 are read-then-write). Locks are transaction-scoped.
func (h *Handler) withPropertyLock(r *http.Request, lockKeys []string, fn func(q *db.Queries) error) error {
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
return err
}
defer tx.Rollback(r.Context())
// Callers pass keys in a fixed global order (workspace before property)
// so overlapping lock sets cannot deadlock.
for _, key := range lockKeys {
if _, err := tx.Exec(r.Context(), "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", key); err != nil {
return err
}
}
if err := fn(h.Queries.WithTx(tx)); err != nil {
return err
}
return tx.Commit(r.Context())
}
// ---------------------------------------------------------------------------
// List-endpoint support: properties filter + property sort expressions
// ---------------------------------------------------------------------------
const (
maxPropertiesFilterDefinitions = 20
maxPropertiesFilterValues = 50
)
// parsePropertiesFilterParam reads the `properties` query parameter — a JSON
// object of {<definitionId>: [<value>, ...]} — and compiles it into OR-groups
// of containment objects: OR within a definition, AND across definitions.
//
// Values are option ids for select/multi_select and "true"/"false" for
// checkbox. The stored value shape differs per type (string, array element,
// boolean), so each value expands to every containment form it could match;
// forms that can't match are simply never satisfied.
//
// Returns (nil, true) when the parameter is empty.
func parsePropertiesFilterParam(w http.ResponseWriter, raw string) ([][]json.RawMessage, bool) {
if raw == "" {
return nil, true
}
var parsed map[string][]string
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
writeError(w, http.StatusBadRequest, "properties filter must be a JSON object of {definitionId: [values]}")
return nil, false
}
if len(parsed) > maxPropertiesFilterDefinitions {
writeError(w, http.StatusBadRequest, fmt.Sprintf("properties filter cannot cover more than %d definitions", maxPropertiesFilterDefinitions))
return nil, false
}
groups := make([][]json.RawMessage, 0, len(parsed))
totalAlternatives := 0
for definitionID, values := range parsed {
if _, err := uuid.Parse(definitionID); err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("properties filter key %q is not a definition id", definitionID))
return nil, false
}
if len(values) == 0 {
continue
}
if len(values) > maxPropertiesFilterValues {
writeError(w, http.StatusBadRequest, fmt.Sprintf("properties filter for %s cannot list more than %d values", definitionID, maxPropertiesFilterValues))
return nil, false
}
alternatives := make([]json.RawMessage, 0, len(values)*3)
appendAlt := func(v any) bool {
buf, err := json.Marshal(map[string]any{definitionID: v})
if err != nil {
writeError(w, http.StatusBadRequest, "properties filter is invalid")
return false
}
alternatives = append(alternatives, buf)
return true
}
for _, value := range values {
if value == "" {
writeError(w, http.StatusBadRequest, "properties filter values cannot be empty")
return nil, false
}
if !appendAlt(value) || !appendAlt([]string{value}) { // select string / multi_select element
return nil, false
}
if value == "true" || value == "false" {
if !appendAlt(value == "true") { // checkbox boolean
return nil, false
}
}
}
totalAlternatives += len(alternatives)
groups = append(groups, alternatives)
}
if len(groups) == 0 {
return nil, true
}
// Bound the OR fan-out: each alternative becomes one bind parameter in
// the SQL below, and a runaway filter would bloat the statement.
if totalAlternatives > 256 {
writeError(w, http.StatusBadRequest, "properties filter is too large")
return nil, false
}
return groups, true
}
// propertiesFilterPredicate renders the AND-of-ORs containment check for a
// compiled filter as plain `i.properties @> $n` disjunctions with one bind
// parameter per alternative. Constant containment operands are what lets the
// planner drive the jsonb_path_ops GIN index (a correlated
// jsonb_array_elements form defeats it — verified via EXPLAIN in review).
func propertiesFilterPredicate(groups [][]json.RawMessage, addArg func(any) string) string {
groupSQL := make([]string, 0, len(groups))
for _, alternatives := range groups {
ors := make([]string, 0, len(alternatives))
for _, alt := range alternatives {
ors = append(ors, fmt.Sprintf("i.properties @> %s::jsonb", addArg(string(alt))))
}
groupSQL = append(groupSQL, "("+strings.Join(ors, " OR ")+")")
}
return "(" + strings.Join(groupSQL, " AND ") + ")"
}
// propertySortExpr resolves a `property:<definitionId>` sort value into a SQL
// ORDER BY expression. Returns handled=false when sortValue is not
// property-shaped (caller falls through to its static whitelist). A malformed
// id writes a 400 (ok=false). An unknown/archived definition or a type that
// has no meaningful order degrades to empty expr — callers keep position
// order, mirroring the frontend's stale-persisted-sort fallback rather than
// breaking installed clients with a 400.
func (h *Handler) propertySortExpr(r *http.Request, workspaceID string, sortValue string) (expr string, handled bool, err error) {
const prefix = "property:"
if !strings.HasPrefix(sortValue, prefix) {
return "", false, nil
}
rawID := strings.TrimPrefix(sortValue, prefix)
parsedID, parseErr := uuid.Parse(rawID)
if parseErr != nil {
return "", true, errors.New("invalid sort value")
}
wsUUID, wsErr := util.ParseUUID(workspaceID)
if wsErr != nil {
return "", true, errors.New("invalid workspace id")
}
var defUUID pgtype.UUID
copy(defUUID.Bytes[:], parsedID[:])
defUUID.Valid = true
def, dbErr := h.Queries.GetIssueProperty(r.Context(), db.GetIssuePropertyParams{ID: defUUID, WorkspaceID: wsUUID})
if dbErr != nil {
if errors.Is(dbErr, pgx.ErrNoRows) {
return "", true, nil // stale sort → position order
}
return "", true, fmt.Errorf("resolve sort property: %w", dbErr)
}
// Archived definitions degrade to position order like unknown ones —
// their values are hidden from the UI, so sorting by them would order
// the list by invisible data.
if def.ArchivedAt.Valid {
return "", true, nil
}
// uuidToString re-serializes the parsed UUID: hex and dashes only, safe
// to embed in the ORDER BY string.
id := uuidToString(def.ID)
switch def.Type {
case "number":
return fmt.Sprintf("CASE WHEN jsonb_typeof(i.properties->'%s') = 'number' THEN (i.properties->>'%s')::numeric END", id, id), true, nil
case "date", "text", "url", "select":
return fmt.Sprintf("NULLIF(i.properties->>'%s', '')", id), true, nil
default: // multi_select, checkbox, future types: no meaningful order
return "", true, nil
}
}