Files
multica/server/internal/handler/property.go
Lambda c402b91c1d 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>
2026-07-15 11:20:14 +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
}
}