mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* feat(core): add app space model for rail navigation Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): replace left sidebar with app rail and space panels Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * test(e2e): drive navigation through the app rail Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): finish the rail and panel shell against the mock - The sidebar toggle always works: in Inbox, Chat and Settings it collapses the page's own list column instead of being disabled. - Move create workspace from the rail into the account menu, and complete the menu: profile, theme and language submenus, shortcuts, docs, change log, Discord, desktop download, log out. - Add an explicit New issue button to the Issues and My issues headers. - Autopilot panel gets a create button and template shortcuts; Team panel gets an invite-member button for owners and admins. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(desktop): draw tabs as pills on the title bar The active tab no longer merges into the content card: with the space panel inside the card, the merged tab landed over whatever column happened to sit below it. Every tab is now a pill on the title bar; the active one uses the card's own surface, inactive ones a lighter tint. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): give list columns the space panel's width Inbox and Chat defaulted to 260px with their own saved widths, Settings was a fixed 240px, and the space panel used the sidebar width. They now share one ListColumn sized by --sidebar-width with the panel's resize edge, so the divider stays in place across spaces and one drag resizes them all. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): return each rail space to where you left it Rail items always linked to the space's home, so switching spaces dropped the project, squad, chat session or settings tab you had open. The shell now remembers the last page (path and query) per space and workspace, and the rail links there; the current space's item still leads home, so a second click resets it. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): animate the list column when the sidebar toggles Inbox, Chat and Settings unmounted their list column on toggle, so it snapped away while the space panel slid. ListColumn now stays mounted and eases its width like the panel (200ms, off under reduced motion and while dragging), which also keeps the list's scroll position across a collapse. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(layout): one title band for every left column The space panels used PanelHeader (title-sm, no rule), Inbox and Chat used the page header (body size, bottom border) and Settings its own 64px title. PanelHeader now takes a heading level, a leading slot and a meta slot, and Inbox, Chat and Settings render their list title with it. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(layout): collapse the whole sidebar with one toggle across spaces Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): switch workspaces from one rail tile Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): preview Inbox, Chat and Settings lists on rail hover Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(inbox): keep the list column's own loading skeleton Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(desktop): lay out the shell for the page on screen during rail switches (MUL-8317) The tab session URL moves before the router commits the next page (it renders in a transition), so for a few frames the shell already showed the incoming space's layout around the outgoing page: switching into Inbox or Chat dropped the Work panel and the old page's header icon jumped to the top-left. The router now publishes its committed pathname from a layout effect and the shell's panel and list columns follow it; space memory keeps following the navigation URL. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): flatten the settings nav into one level of section headings (MUL-8317) Scope header rows, sub-group headings and dividers stacked three levels into the 240px list column. Every section is now a peer with the space panels' section label and spacing; each page already names its scope in a badge. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): expand projects inline in the work panel (MUL-8317) Project rows in the Issues panel get a chevron that lists the project's open issues in place, so the sidebar can move between issues without opening the project page. The issue on screen reveals its project and stays lit; expansion persists per workspace. A dedicated open-issues cache follows realtime title patches and refetches only when status or project membership moves. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): sidebar tree settings, load more, expandable views and project order (MUL-8317) - Expanded projects and views list issues from the server (updated date, finished statuses hidden) with Load more / Show less. - One shared issue display popover on hovered expanded projects; a project display popover on the Projects header (ordering, statuses, only mine, counts); drag to reorder; collapsible section; no project cap. - Issue rows shimmer and show agent avatars while an agent works or waits. - Settings, order and expansion persist per workspace. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(views): show sidebar status filters as checkboxes (MUL-8317) Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(ui): auto-collapse the nav only below lg (MUL-8317) The rail now stays on screen beside the space panel, so laptop-sized windows (1024-1279px, just under the 1280 desktop default) have room for the panel; they no longer open Issues with it collapsed. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): one Projects display popover, higher-contrast filter chips (MUL-8317) Issue settings are shared by every project, so they move from each expanded row into the Projects header popover beside the project settings. Status filters go back to chips: a solid outline and tint when on, dashed and faded when off. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(server): list views across scopes and store sidebar view order Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): starred views sidebar, unified view header and All views page - Sidebar Views lists only starred views (built-in and saved, user-ordered) with an All views row; order lives in a per-user sidebar preference. - Issues, My issues and project pages share one header: view name menu, folding filter chips (+N), assignee-type / relation chip, Reset and Save. - Saved-view filters are fully editable; new views are created directly and renamed inline instead of through the save dialog. - New All views directory with tabs, search and star toggles. - Project page drops the breadcrumb and hides the redundant project chip. - Remove the view bar, save/manage dialogs and the old headers. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(inbox): switch between inbox and archive with header tabs Replace the Archived row that trailed the notification list (and the back row inside the archive) with Inbox | Archived tabs pinned under the list header. The unread count moves onto the Inbox tab, and an empty archive no longer says the inbox is empty. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * Revert "feat(inbox): switch between inbox and archive with header tabs" This reverts commit e8a8e1bcea1bf5b45c4e5830d654523a0b77daff. Co-authored-by: multica-agent <[email protected]> * fix(inbox): drop the count beside the no-agents-working filter The count widened the whole filter menu for a toggle whose result is visible in the list as soon as it is applied. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(inbox): switch to the archive from the page title The Archived entry trailed the notification list and scrolled away with it. The list title is now a menu (Inbox / Archived), so the switch stays in reach without adding a row, and the archive's back row is gone. An empty archive no longer says the inbox is empty. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): keep a pinned issue lit after its URL turns canonical The issue route replaces /issues/<uuid> with /issues/MUL-123, but a pin only matched its own UUID href, so clicking a pinned issue lit the same issue under its project instead. Issue pins now match the open issue by id, a lit pin no longer auto-expands its project, and pinned issues show the running agent the same way tree rows do. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): line pinned agent avatars up with the tree rows The avatar sat inside the gap reserved for the unpin button, so pinned avatars landed left of the project tree's and the label lost room. A pinned issue with agents now puts the avatar in the row's right edge and hands that slot to the unpin button on hover. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): raise the contrast of the working-agent shimmer ShimmerText now reads --shimmer-text-base, --shimmer-text-highlight and --shimmer-text-spread, defaulting to the previous values. Sidebar issue rows (tree and Pinned) rest on faint-foreground and use a 22% highlight band so the sweep is visible at sidebar size; chat, the issue page and other shimmer hosts are unchanged. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): keep the view header from overlapping on narrow widths The view-name button kept the Button base shrink-0, so once the h1 hit its min width the name painted over the add-filter trigger (visible on a 390px web viewport). The button now shrinks and truncates, the add-filter label becomes screen-reader-only in a narrow header, and FoldingFilterChips re-measures its reserved width when its leading or trailing controls resize instead of only when the chips change. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): one Filter dropdown and a header that adapts to its width The view header no longer lays filters out as chips with a +N overflow. A single Filter button carries the active count and opens the filter menu, which now lists the dimensions already filtering first, each showing its values, with the scope axis (assignee type, relation to me) at the top. The split is taken when the menu opens so a row never moves mid-edit. The header measures itself: when the title side runs out of room it first folds the display controls to icons, then the agents chip and New issue, before the title truncates, and only releases a step once the row is wider than the width that forced it. This fixes the overlap in saved views with table controls and after toggling agents working. FoldingFilterChips and single-row-fit had no other users and are removed. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): keep the highlight on the row that was clicked An issue that is both pinned and listed in the tree always lit its pin, so clicking it under a project moved the highlight away from the row just clicked. The work panel now remembers which section a click on an issue link came from: a click in the tree keeps the tree row lit and the pin unlit, a click on the pin keeps the pin. Arriving any other way (search, a link, a shortcut) still lights the pin. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(sidebar): let Views and Pinned collapse like Projects Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): keep a dragged row at its own height while reordering (MUL-8354) (#9662) Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(sidebar): create an issue in a project from its row Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): put the workflow beside the title and set the panel toggle apart The project workflow now sits on the left with the title and Filter, in the same borderless style. New issue matches Display's height, and a divider separates the right-sidebar toggle from the header actions. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): fold the layout switcher into the Display panel One Display button whose icon follows the layout. The panel opens with the layouts as a segmented row, then grouping, ordering and sub-issues, then the current layout's own options. The table's grouping and column pickers move in from the header. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): keep hovered Display chips from looking selected Light mode's muted and accent are the same grey, so a hovered chip matched a pressed one. Hover now only darkens the text and border, and a pressed chip fills with the stronger input tone. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(sidebar): give the Projects settings the Display panel's controls Both panels now share one chip and one sort-direction button. The sidebar popover drops its dividers and dashed off-chips, takes the same section headings, and its selects match the Display panel's width. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): quiet the issue view header down to its title The header carried six pieces of text, half of which said nothing: the project's workflow name, "0 agents working" and "Display". - Workflow moves into the project title menu as a submenu; the properties panel keeps its row. - Filter becomes an icon beside the title, followed by Reset / Save, so the count sits next to the actions it causes. Its tooltip names the active filter fields. - Display and New issue become ghost icon buttons named by tooltips, with New issue showing its C shortcut. - The agents chip shows only while someone is working or its filter is on, as avatars and a number. With every control already an icon, the crowding steps are gone. MUL-8361 Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(issues): is / is not per filter, Relation to me, and a hover summary (MUL-8353) (#9663) * feat(issues): is / is not per filter, a Relation to me filter, and a hover summary (MUL-8353) Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): match the Relation filter's Chinese labels to My issues (MUL-8353) Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): advanced filter mode, menu search, and due date filter Advanced mode turns the simple filters into a rule tree (match all/any, is/is not per condition, one level of groups) edited in a panel behind the same Filter trigger. Switching back is offered when the tree is something simple mode can say. The filter menu gets a search box that matches fields and values; a value hit reads as the condition it adds. Due date joins as a dimension with relative buckets (overdue, today, this week, next 7 days, none). The issue table query accepts filters.advanced, filters.due_dates and filters.today, compiled to SQL with the same IS NOT TRUE negation. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * test(e2e): menu search, due date and advanced filter Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> Co-authored-by: Cursor <[email protected]> * test(e2e): let menus settle before filter screenshots Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(views): advanced filter keeps status columns, property filters and negated totals - Columns follow a status the rule tree requires, and a status it names un-hides its column, so switching to advanced no longer drops rows. - Status branch totals drop the excluded keys for "is not" instead of falling back to the loaded count. - The advanced panel shows property filters as editable rows with an "Add property filter" entry, and Clear all clears them too. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): make custom properties ordinary advanced filter conditions Properties are fields in the advanced tree: they sit in the field picker after the built-in fields, can live in groups and follow all/any, and edit their values with the property's own options. The server compiles a property:<id> condition with the existing property predicate. Entering advanced mode moves property filters into the tree, so the separate property section and its Add property filter button are gone. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): typed filter operators and direct condition groups - Add condition group inserts an empty group and opens its own add menu, instead of asking for a field first. The group's header carries its match ("Match any of these"); rows only show the joining word. - A row's menu can turn it into a group, and a tree with groups reads back as one sentence under the rows. - Operators follow the field's type: is / is not, includes any / none of for labels and multi-value properties, contains for text, comparisons for numbers, before / after for dates, and is empty / is not empty for anything that can be unset. Text, number and date values are typed in the row. Every operator is a reading of the stored negated + values, so saved views and the server query are unchanged. - Actor value headings sit in a menu group, which Base UI requires. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(layout): New issue and search at the far end of the desktop title bar New issue lived only as a pen in the Issues panel header, so Inbox, Team, Chat and Auto had no way to start an issue but the C shortcut. Search sat in the rail between the workspace tile and the spaces. - Desktop: New issue and search are icon buttons at the right end of the title bar, styled like Back/Forward, with search last. The tabs give way to them. The rail drops search. - Web has no title bar, so its rail keeps both under the workspace tile, New issue first. - The button does what C does: a project page files the issue into that project. Both now share one helper. - The Issues panel header loses its pen; the page header keeps New issue. MUL-8359 Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): remove horizontal scrollbar from the filter menu Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(sidebar): every view expands in the sidebar Saved views preview through the table query, which expresses every view filter (is not, relation, due date, rule trees, my-any). Built-in views expand too, scoped like their page and listed with the sidebar issue settings. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(sidebar): group expanded rows by spacing, folder icon for projects Issues under an expanded row drop to 26px rows with no gap and a 12px status icon, an open group ends with 8px of space, and the expanded row's name goes full-strength. Sections sit 20px apart. Sidebar projects show a folder icon instead of their emoji. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): expandable rows give their label the full width The hidden chevron, an empty spacer and their gaps reserved about 90px at the end of every expandable row, so names truncated with space to spare. The chevron now takes the icon's place on hover. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): tree row chevron swaps back to the icon immediately Drop the opacity fades, and show the chevron for keyboard focus only, so a clicked row no longer keeps it until focus moves. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): drop the hint beside Switch to advanced filter The grey text sat where the menu shows current values, so it read as a setting rather than an explanation. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(layout): tighten the gap under panel titles, keep selected rows at regular weight The first section kept the 20px that separates sections, so it sat far below the panel title. Selected and expanded rows, desktop tabs, the Settings nav, agent tabs and the skills file tree no longer switch to medium weight: the wider glyphs made the label grow when it was clicked. Selection reads from the background and text colour. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(sidebar): keep stars while loading, leave view state to in-place clicks, page past 100 - Star, unstar and reorder write the stored order, not the resolved items, and wait until that order is known, so views still loading are kept. - Sidebar rows switch the active view only when the click navigates this page; a new tab takes its target from the URL. - Sidebar issue lists walk 100-row pages so Load more keeps growing. - Drop a trailing blank line in advanced-filter-panel. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(views): open the clicked view in desktop tabs, prepare view state only on in-place navigation - Desktop tabs carry their own ?view=: list pages adopt it on mount and on back/forward, write view changes back with replace, and the active-view map is swapped with the active tab. A saved view is part of tab identity, so cmd+clicking view B from a tab on view A opens B in a new tab. - AppLink and useRowLink call onNavigateInPlace right before they push, so sidebar and All views rows prepare view state exactly when the page navigates in place (desktop shift-click included, web shift-click not). - Rail items and the workspace tile use named radius tokens. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): one page system for lists and objects Every page now follows the Issues page: one 48px header, nothing stacked under it. - Lists entered from the sidebar (agents, squads, skills, runtimes, projects, views, autopilots, issue wakeups) title themselves with an icon and a scope menu, and keep only icon tools: filter with its count and Reset, display, and "+". Counts left the header; list search is removed. - Objects (agent, squad, skill, runtime, autopilot, member) open under a breadcrumb with at most one solid action and a ⋯ menu. Their tabs became body sections with actions on the section titles, in a 760px column, with a 300px properties panel the header toggles. - Analytics keeps its tabs, now in the header; settings, chat and the create-agent flow share the same header. - Shared CollectionPage* and ObjectPage* primitives in views/layout. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): one CollectionList for every list page Replace the per-page tables and ListGrid with a shared CollectionList in packages/views/layout: single-line 36px rows with inline grey descriptions, a shared column/grid template, header sort, row selection with shift-range, keyboard navigation (arrows, J/K, Enter, X, Cmd+A, Esc), right-click and kebab row menus, load-more, and a shared batch bar. - createCollectionViewStore factory in core; display menus are generated from the column definitions. - Agents, Squads, Skills, Projects, Autopilots, Runtimes (page and RuntimeList), Views and Wakeups migrate to it; Wakeups pages with load-more instead of prev/next. - The Issues batch toolbar uses the shared CollectionBatchBar. - Agents hides owner and access by default so the default columns fit at 1280px; persisted v1 defaults migrate, custom choices are kept. - Remove ListGrid and the now-unused per-page locale keys. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(layout): smooth the nav panel toggle - Ease the panel and list columns with a soft-start curve (220ms ease) instead of ease-out, whose first frame jumped a quarter of the width and, when that frame dropped, nearly half of it. - End-align the panel content and clip it while it grows, so it slides in with the edge instead of spilling under the page; the resize handle moves to a zero-width sibling so the clip does not cut it. - The floating chat stays mounted while closed and re-rendered on every frame of the toggle as its container resized; it now re-renders only when the clamped window size changes. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(issues): give My agents' work its own URL My agents' work shared /my-issues with My issues and was told apart only by a persisted global scope, so it could not open in its own tab, and switching one tab flipped every other. It now lives at /my-issues?scope=agents: the page, header and sidebar read the scope from the URL, desktop tab dedupe keys on it, and the tab gets its own title and icon. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(views): preserve My agents' work in copied links Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]>
1297 lines
48 KiB
Go
1297 lines
48 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"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/issueproperty"
|
|
"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 = 200
|
|
maxPropertyNameLen = 32
|
|
maxPropertyIconLen = 32
|
|
maxPropertyDescriptionLen = 500
|
|
maxPropertyTextValueLen = 2000
|
|
maxPropertyURLValueLen = 2048
|
|
// multi_actor is capped well below the select cap: the whole properties
|
|
// bag shares one 16KB row budget, and a property holding hundreds of
|
|
// actors would crowd out every other property on the same issue.
|
|
maxPropertyActorValues = 20
|
|
)
|
|
|
|
var validPropertyTypes = []string{"text", "number", "select", "multi_select", "date", "checkbox", "url", "actor", "multi_actor", "multi_text", "multi_url"}
|
|
|
|
// Property icons use stable catalog keys that the Web client maps to its
|
|
// icon glyphs. Keeping this allowlist at the API boundary prevents arbitrary text
|
|
// (including emoji) from leaking into every issue surface that renders icons.
|
|
var validPropertyIcons = map[string]struct{}{
|
|
"circle-dot": {}, "signal-high": {}, "user-round": {}, "folder-kanban": {},
|
|
"calendar-days": {}, "tag": {}, "milestone": {}, "flag": {}, "bookmark": {},
|
|
"star": {}, "target": {}, "shield": {}, "bug": {}, "zap": {}, "rocket": {},
|
|
"sparkles": {}, "lightbulb": {}, "globe-2": {}, "link": {}, "hash": {},
|
|
"list-checks": {}, "circle-check": {}, "clock-3": {}, "briefcase-business": {},
|
|
"layers-3": {}, "gauge": {}, "database": {}, "code-2": {}, "palette": {},
|
|
"megaphone": {}, "map-pin": {}, "package": {}, "wrench": {}, "heart": {},
|
|
"circle-alert": {}, "lock-keyhole": {},
|
|
}
|
|
|
|
// 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"`
|
|
Icon string `json:"icon"`
|
|
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,
|
|
Icon: p.Icon,
|
|
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,
|
|
Icon: row.Icon,
|
|
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"`
|
|
Icon string `json:"icon"`
|
|
Config *PropertyConfig `json:"config"`
|
|
}
|
|
|
|
type UpdatePropertyRequest struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
Icon *string `json:"icon"`
|
|
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 validatePropertyIcon(raw string) (string, error) {
|
|
for _, r := range raw {
|
|
if unicode.IsControl(r) {
|
|
return "", errors.New("icon cannot contain tabs, newlines, or control characters")
|
|
}
|
|
}
|
|
icon := strings.TrimSpace(raw)
|
|
if utf8.RuneCountInString(icon) > maxPropertyIconLen {
|
|
return "", fmt.Errorf("icon must be %d characters or fewer", maxPropertyIconLen)
|
|
}
|
|
if icon == "" {
|
|
return "", nil
|
|
}
|
|
if _, ok := validPropertyIcons[icon]; !ok {
|
|
return "", errors.New("icon must be a supported icon key")
|
|
}
|
|
return icon, 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..200 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, ", ")
|
|
}
|
|
|
|
// Actor values (MUL-6286)
|
|
|
|
// Compatibility aliases keep the focused handler tests on the same helper
|
|
// names while the implementation is shared with IssueService.Create.
|
|
type actorRef = issueproperty.ActorRef
|
|
|
|
func propertyTypeIsActor(t string) bool { return issueproperty.IsActor(t) }
|
|
func actorKindsHint() string { return issueproperty.ActorKindsHint() }
|
|
func parseActorRef(s string) (actorRef, error) {
|
|
return issueproperty.ParseActorRef(s)
|
|
}
|
|
func parseActorRefList(items []any) ([]actorRef, error) {
|
|
return issueproperty.ParseActorRefList(items)
|
|
}
|
|
func actorRefsInValue(propType string, stored []byte) ([]actorRef, error) {
|
|
return issueproperty.ActorRefsInValue(propType, stored)
|
|
}
|
|
|
|
// resolveActorRefs checks that every reference points at a real member of this
|
|
// workspace. No visibility gate is needed while members are the only kind:
|
|
// workspace membership is already visible to every member. Adding a kind that
|
|
// is not (an agent) means adding that gate back — see actorPropertyKinds.
|
|
func (h *Handler) resolveActorRefs(r *http.Request, workspaceID string, refs []actorRef) (int, string) {
|
|
ctx := r.Context()
|
|
wsUUID, err := util.ParseUUID(workspaceID)
|
|
if err != nil {
|
|
return http.StatusBadRequest, "invalid workspace_id"
|
|
}
|
|
for _, ref := range refs {
|
|
refUUID, err := util.ParseUUID(ref.ID)
|
|
if err != nil {
|
|
return http.StatusBadRequest, fmt.Sprintf("actor id in %q must be a UUID", ref)
|
|
}
|
|
if _, err := h.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
|
UserID: refUUID,
|
|
WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
return http.StatusBadRequest, fmt.Sprintf("%q does not refer to a member of this workspace", ref)
|
|
}
|
|
}
|
|
return 0, ""
|
|
}
|
|
|
|
// 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) {
|
|
return issueproperty.ValidateValue(def, raw)
|
|
}
|
|
|
|
// 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 {
|
|
return util.JSONObjectOrEmpty(raw)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
icon, err := validatePropertyIcon(req.Icon)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
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)),
|
|
Icon: icon,
|
|
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.Icon != nil {
|
|
icon, err := validatePropertyIcon(*req.Icon)
|
|
if err != nil {
|
|
return fail(http.StatusBadRequest, err.Error())
|
|
}
|
|
params.Icon = pgtype.Text{String: icon, 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) {
|
|
r = h.withWakeupActor(r)
|
|
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())
|
|
}
|
|
// Actor values point at another entity, so shape validation isn't
|
|
// enough: resolve each reference against this workspace before the
|
|
// write, and reject references the caller isn't allowed to see.
|
|
if propertyTypeIsActor(def.Type) {
|
|
refs, err := actorRefsInValue(def.Type, value)
|
|
if err != nil {
|
|
return fail(http.StatusBadRequest, err.Error())
|
|
}
|
|
if status, msg := h.resolveActorRefs(r, uuidToString(issue.WorkspaceID), refs); status != 0 {
|
|
return fail(status, msg)
|
|
}
|
|
}
|
|
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,
|
|
"issue_revision": updated.Revision,
|
|
})
|
|
writeJSON(w, http.StatusOK, map[string]any{"properties": properties, "issue_revision": updated.Revision})
|
|
}
|
|
|
|
func (h *Handler) DeleteIssueProperty(w http.ResponseWriter, r *http.Request) {
|
|
r = h.withWakeupActor(r)
|
|
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 := wakeupWrite(h, r, func(q *db.Queries) (db.Issue, error) {
|
|
return q.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,
|
|
"issue_revision": updated.Revision,
|
|
})
|
|
writeJSON(w, http.StatusOK, map[string]any{"properties": properties, "issue_revision": updated.Revision})
|
|
}
|
|
|
|
// 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.beginWakeupWrite(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
|
|
// noPropertyValue is the filter value that means "unset" — it compiles to a
|
|
// key-absence predicate instead of a jsonb containment pattern. The string
|
|
// cannot collide with a real option id (select option ids are UUIDs and
|
|
// checkbox uses "true"/"false").
|
|
noPropertyValue = "__none__"
|
|
// operatorPatternKey marks a compiled operator alternative (see
|
|
// parsePropertiesFilterParam). Neither this nor the sibling keys spell a
|
|
// UUID, so the marker can never collide with a containment pattern, whose
|
|
// single key is the definition id.
|
|
operatorPatternKey = "__op__"
|
|
)
|
|
|
|
// propertyFilterOperator is a structured member of a properties filter —
|
|
// {"op": "contains", "value": "foo"} — alongside plain string members, which
|
|
// keep meaning exact equality. Op semantics:
|
|
//
|
|
// - contains: case-insensitive substring over the value's text form
|
|
// (text / url).
|
|
// - gt / gte / lt / lte: numeric comparison, matched only against stored
|
|
// jsonb numbers (number).
|
|
// - before / after: lexicographic comparison against stored strings, which
|
|
// is chronological for the "YYYY-MM-DD" date-only strings date
|
|
// properties store (date).
|
|
type propertyFilterOperator struct {
|
|
Op string `json:"op"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// propertyOperatorPattern is the compiled operator alternative: the JSON
|
|
// object {"__op__": "<op>", "def": "<definitionId>", "value": "<value>"} that
|
|
// both propertiesFilterPredicate and the static ListOpenIssues unroll
|
|
// recognize. For `contains` the value is stored already ILIKE-escaped so both
|
|
// consumers can concatenate it into the pattern directly.
|
|
type propertyOperatorPattern struct {
|
|
Op string `json:"__op__"`
|
|
Def string `json:"def"`
|
|
Value string `json:"value"`
|
|
// Prefilter repeats Value for the `contains` needles worth pre-screening
|
|
// against LOWER(properties::text) (see prefilterableContainsNeedle). It is
|
|
// absent — never empty — for the rest, so the static unroll can test for
|
|
// the key.
|
|
Prefilter string `json:"prefilter,omitempty"`
|
|
}
|
|
|
|
var propertyFilterOps = map[string]string{
|
|
"contains": "",
|
|
"gt": ">",
|
|
"gte": ">=",
|
|
"lt": "<",
|
|
"lte": "<=",
|
|
"before": "<",
|
|
"after": ">",
|
|
}
|
|
|
|
// escapeLikePattern escapes SQL LIKE wildcards for a literal substring match
|
|
// under the default backslash escape character.
|
|
func escapeLikePattern(s string) string {
|
|
replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
|
return replacer.Replace(s)
|
|
}
|
|
|
|
// prefilterableContainsNeedle reports whether a raw `contains` needle may be
|
|
// pre-screened against LOWER(properties::text), the expression
|
|
// idx_issue_properties_bigm indexes (migration 446). A needle that cannot falls
|
|
// back to today's per-key-only filtering: slower on a large workspace, never
|
|
// wrong.
|
|
//
|
|
// The one disqualifier is JSON escaping. Postgres writes jsonb strings through
|
|
// escape_json, which escapes `"`, `\` and every character below U+0020; a
|
|
// needle containing one of those has no literal occurrence in properties::text,
|
|
// so pre-screening on it would drop rows the per-key ILIKE does match.
|
|
//
|
|
// Everything else matches literally and is prefiltered, however short. CJK and
|
|
// other non-ASCII text is passed through by escape_json in every server
|
|
// encoding, and pg_bigm indexes 1- and 2-character keywords — the capability it
|
|
// exists for over pg_trgm, and the length at which a CJK needle is already a
|
|
// real word. Length is deliberately not a condition here: excluding short
|
|
// needles would exclude exactly the searches this index was chosen to serve.
|
|
func prefilterableContainsNeedle(needle string) bool {
|
|
return !strings.ContainsFunc(needle, func(r rune) bool {
|
|
return r == '"' || r == '\\' || r < 0x20
|
|
})
|
|
}
|
|
|
|
// validatePropertyFilterOperator checks one operator member and returns the
|
|
// compiled pattern. The value rules mirror the legacy string rules: non-empty,
|
|
// bounded, and only comparable shapes (a float for numeric ops, a real
|
|
// date-only string for before/after).
|
|
func validatePropertyFilterOperator(definitionID string, op propertyFilterOperator) (propertyOperatorPattern, error) {
|
|
if _, known := propertyFilterOps[op.Op]; !known {
|
|
return propertyOperatorPattern{}, fmt.Errorf("properties filter op %q is not supported", op.Op)
|
|
}
|
|
if op.Value == "" {
|
|
return propertyOperatorPattern{}, errors.New("properties filter operator values cannot be empty")
|
|
}
|
|
pattern := propertyOperatorPattern{Op: op.Op, Def: definitionID, Value: op.Value}
|
|
switch op.Op {
|
|
case "contains":
|
|
if utf8.RuneCountInString(op.Value) > maxPropertyTextValueLen {
|
|
return propertyOperatorPattern{}, fmt.Errorf("properties filter value must be %d characters or fewer", maxPropertyTextValueLen)
|
|
}
|
|
pattern.Value = escapeLikePattern(op.Value)
|
|
if prefilterableContainsNeedle(op.Value) {
|
|
pattern.Prefilter = pattern.Value
|
|
}
|
|
case "gt", "gte", "lt", "lte":
|
|
num, err := strconv.ParseFloat(op.Value, 64)
|
|
if err != nil || math.IsNaN(num) || math.IsInf(num, 0) {
|
|
return propertyOperatorPattern{}, fmt.Errorf("properties filter op %q requires a finite number", op.Op)
|
|
}
|
|
// Canonicalize to plain decimal before storing: ParseFloat accepts forms
|
|
// Postgres ::numeric rejects ("0x1p4" hex-float, "1_000" underscores on
|
|
// older PG), and the static open_only unroll casts this exact string
|
|
// inside SQL — an uncanonicalized value would 500 that path while the
|
|
// dynamic path (which binds the parsed float) succeeds.
|
|
pattern.Value = strconv.FormatFloat(num, 'f', -1, 64)
|
|
case "before", "after":
|
|
if _, err := time.Parse("2006-01-02", op.Value); err != nil {
|
|
return propertyOperatorPattern{}, fmt.Errorf("properties filter op %q requires a YYYY-MM-DD date", op.Op)
|
|
}
|
|
}
|
|
return pattern, nil
|
|
}
|
|
|
|
// parsePropertiesFilterParam reads the `properties` query parameter — a JSON
|
|
// object of {<definitionId>: [<value>, ...]} — and compiles it into OR-groups
|
|
// of alternatives: OR within a definition, AND across definitions.
|
|
//
|
|
// A plain string member means equality; it expands to every containment form
|
|
// it could match (option id string, array element, boolean, jsonb number),
|
|
// and the forms that can't match are simply never satisfied. Values are
|
|
// option ids for select/multi_select and "true"/"false" for checkbox.
|
|
//
|
|
// An object member {"op", "value"} is a scalar operator (see
|
|
// propertyFilterOperator) and compiles to one operator pattern.
|
|
//
|
|
// The special value noPropertyValue ("__none__") means "unset": it emits the
|
|
// marker object {"__none__": "<definitionId>"} that parseNoPropertyValuePattern
|
|
// and the static ListOpenIssues unroll both recognize as a key-absence check.
|
|
//
|
|
// 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][]json.RawMessage
|
|
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
|
|
}
|
|
groups, err := compilePropertiesFilter(parsed)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return nil, false
|
|
}
|
|
return groups, true
|
|
}
|
|
|
|
// compilePropertiesFilter is parsePropertiesFilterParam after decoding; the
|
|
// error is the 400 message. Returns nil when nothing constrains.
|
|
func compilePropertiesFilter(parsed map[string][]json.RawMessage) ([][]json.RawMessage, error) {
|
|
errInvalid := errors.New("properties filter is invalid")
|
|
if len(parsed) > maxPropertiesFilterDefinitions {
|
|
return nil, fmt.Errorf("properties filter cannot cover more than %d definitions", maxPropertiesFilterDefinitions)
|
|
}
|
|
groups := make([][]json.RawMessage, 0, len(parsed))
|
|
totalAlternatives := 0
|
|
for definitionID, values := range parsed {
|
|
if _, err := uuid.Parse(definitionID); err != nil {
|
|
return nil, fmt.Errorf("properties filter key %q is not a definition id", definitionID)
|
|
}
|
|
if len(values) == 0 {
|
|
continue
|
|
}
|
|
if len(values) > maxPropertiesFilterValues {
|
|
return nil, fmt.Errorf("properties filter for %s cannot list more than %d values", definitionID, maxPropertiesFilterValues)
|
|
}
|
|
alternatives := make([]json.RawMessage, 0, len(values)*3)
|
|
appendAlt := func(v any) error {
|
|
buf, err := json.Marshal(map[string]any{definitionID: v})
|
|
if err != nil {
|
|
return errInvalid
|
|
}
|
|
alternatives = append(alternatives, buf)
|
|
return nil
|
|
}
|
|
hasNoValue := false
|
|
for _, rawValue := range values {
|
|
// A plain string member keeps the legacy equality semantics.
|
|
var stringValue string
|
|
if err := json.Unmarshal(rawValue, &stringValue); err == nil {
|
|
value := stringValue
|
|
if value == "" {
|
|
return nil, errors.New("properties filter values cannot be empty")
|
|
}
|
|
if value == noPropertyValue {
|
|
if hasNoValue {
|
|
continue
|
|
}
|
|
marker, err := json.Marshal(map[string]string{noPropertyValue: definitionID})
|
|
if err != nil {
|
|
return nil, errInvalid
|
|
}
|
|
alternatives = append(alternatives, marker)
|
|
hasNoValue = true
|
|
continue
|
|
}
|
|
// select string / multi_select element
|
|
if err := appendAlt(value); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := appendAlt([]string{value}); err != nil {
|
|
return nil, err
|
|
}
|
|
if value == "true" || value == "false" {
|
|
if err := appendAlt(value == "true"); err != nil { // checkbox boolean
|
|
return nil, err
|
|
}
|
|
}
|
|
if num, err := strconv.ParseFloat(value, 64); err == nil &&
|
|
!math.IsNaN(num) && !math.IsInf(num, 0) {
|
|
// number property scalar: a numeric filter value must match the
|
|
// stored jsonb number, not the string form appended above. NaN /
|
|
// Infinity are skipped: they are not representable as JSON, so
|
|
// marshaling them would 400 the whole filter.
|
|
if err := appendAlt(num); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
// An object member is a scalar operator.
|
|
var op propertyFilterOperator
|
|
if err := json.Unmarshal(rawValue, &op); err != nil || op.Op == "" {
|
|
return nil, errors.New("properties filter values must be strings or {op, value} objects")
|
|
}
|
|
pattern, err := validatePropertyFilterOperator(definitionID, op)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
buf, err := json.Marshal(pattern)
|
|
if err != nil {
|
|
return nil, errInvalid
|
|
}
|
|
alternatives = append(alternatives, buf)
|
|
}
|
|
totalAlternatives += len(alternatives)
|
|
groups = append(groups, alternatives)
|
|
}
|
|
if len(groups) == 0 {
|
|
return nil, nil
|
|
}
|
|
// 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 {
|
|
return nil, errors.New("properties filter is too large")
|
|
}
|
|
return groups, nil
|
|
}
|
|
|
|
// parseNoPropertyValuePattern reports whether an alternative is the synthesized
|
|
// "no value" marker — the jsonb object {"__none__": "<definitionId>"} — and
|
|
// returns the definition id whose key-absence the predicate must test.
|
|
func parseNoPropertyValuePattern(alt json.RawMessage) (string, bool) {
|
|
var marker map[string]string
|
|
if err := json.Unmarshal(alt, &marker); err != nil {
|
|
return "", false
|
|
}
|
|
defID, ok := marker[noPropertyValue]
|
|
return defID, ok
|
|
}
|
|
|
|
// parseOperatorPattern reports whether an alternative is a compiled scalar
|
|
// operator (see parsePropertiesFilterParam) and returns its parts. Containment
|
|
// patterns never carry the "__op__" key, so the two shapes cannot be confused.
|
|
func parseOperatorPattern(alt json.RawMessage) (propertyOperatorPattern, bool) {
|
|
var marker map[string]string
|
|
if err := json.Unmarshal(alt, &marker); err != nil {
|
|
return propertyOperatorPattern{}, false
|
|
}
|
|
op, hasOp := marker[operatorPatternKey]
|
|
def, hasDef := marker["def"]
|
|
if !hasOp || !hasDef {
|
|
return propertyOperatorPattern{}, false
|
|
}
|
|
return propertyOperatorPattern{Op: op, Def: def, Value: marker["value"], Prefilter: marker["prefilter"]}, true
|
|
}
|
|
|
|
// operatorPatternPredicate renders one operator alternative as SQL. Ops were
|
|
// validated at parse time, so the re-parses below cannot fail for compiled
|
|
// input; a malformed pattern degrades to FALSE rather than matching.
|
|
func operatorPatternPredicate(pattern propertyOperatorPattern, addArg func(any) string) string {
|
|
defArg := addArg(pattern.Def)
|
|
switch pattern.Op {
|
|
case "contains":
|
|
// Value is ILIKE-escaped at parse time. Restrict substring matching to
|
|
// stored strings: ->> also serializes numbers, booleans, and arrays, while
|
|
// the client matcher intentionally treats contains as a text/url operator.
|
|
match := fmt.Sprintf("(jsonb_typeof(i.properties -> %s) = 'string' AND (i.properties ->> %s) ILIKE '%%' || %s || '%%')",
|
|
defArg, defArg, addArg(pattern.Value))
|
|
if pattern.Prefilter == "" {
|
|
return match
|
|
}
|
|
// Redundant prefilter over the whole object's text form, which
|
|
// idx_issue_properties_bigm indexes (migration 446). The per-key check
|
|
// above still decides the result — this one only narrows the candidate
|
|
// set from "every issue in the workspace" to what the bigram index
|
|
// returns, and by construction (prefilterableContainsNeedle) never
|
|
// drops a row the per-key check would keep.
|
|
//
|
|
// LOWER(...) LIKE LOWER(...) rather than a second ILIKE: pg_bigm 1.2 has
|
|
// no ILIKE index scan (migration 036), and lowering both sides in SQL is
|
|
// exactly how ILIKE folds case, so the two cannot disagree.
|
|
return fmt.Sprintf("(LOWER(i.properties::text) LIKE LOWER('%%' || %s || '%%') AND %s)",
|
|
addArg(pattern.Prefilter), match)
|
|
case "gt", "gte", "lt", "lte":
|
|
// Bind the canonical decimal as numeric on every serving path. CASE makes
|
|
// the jsonb type guard structural instead of relying on SQL qualifier
|
|
// evaluation order before the cast.
|
|
num, err := strconv.ParseFloat(pattern.Value, 64)
|
|
if err != nil || math.IsNaN(num) || math.IsInf(num, 0) {
|
|
return "FALSE"
|
|
}
|
|
canonical := strconv.FormatFloat(num, 'f', -1, 64)
|
|
return fmt.Sprintf("(CASE WHEN jsonb_typeof(i.properties -> %s) = 'number' THEN (i.properties ->> %s)::numeric END %s %s::numeric)",
|
|
defArg, defArg, propertyFilterOps[pattern.Op], addArg(canonical))
|
|
case "before", "after":
|
|
return fmt.Sprintf("(jsonb_typeof(i.properties -> %s) = 'string' AND i.properties ->> %s %s %s)",
|
|
defArg, defArg, propertyFilterOps[pattern.Op], addArg(pattern.Value))
|
|
default:
|
|
return "FALSE"
|
|
}
|
|
}
|
|
|
|
// propertiesFilterPredicate renders the AND-of-ORs filter check with one bind
|
|
// parameter per alternative. Equality alternatives are plain
|
|
// `i.properties @> $n` containment disjunctions — constant 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).
|
|
//
|
|
// A "no value" marker alternative renders as a key-absence disjunction —
|
|
// `NOT (i.properties ? $m)` — which cannot use the GIN index but is exact for
|
|
// the unset state (property values are never null; DELETE unsets). An operator
|
|
// alternative renders as its typed comparison (ILIKE / numeric / date string):
|
|
// scalar ranges fundamentally cannot use a containment index, and only
|
|
// `contains` gets an indexable prefilter in front of it (see
|
|
// operatorPatternPredicate).
|
|
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 {
|
|
if defID, ok := parseNoPropertyValuePattern(alt); ok {
|
|
ors = append(ors, fmt.Sprintf("NOT (i.properties ? %s)", addArg(defID)))
|
|
continue
|
|
}
|
|
if pattern, ok := parseOperatorPattern(alt); ok {
|
|
ors = append(ors, operatorPatternPredicate(pattern, addArg))
|
|
continue
|
|
}
|
|
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.
|
|
//
|
|
// The literal token "::numeric" in the number and select branches is a
|
|
// contract, not a formatting choice: issueTableOrderBy sniffs it
|
|
// (strings.Contains) to give the keyset cursor a numeric cast instead of
|
|
// text. Writing e.g. "::integer" would silently break table pagination.
|
|
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 "select":
|
|
return selectPropertySortExpr(id, parsePropertyConfig(def.Config)), true, nil
|
|
case "date", "text", "url":
|
|
return fmt.Sprintf("NULLIF(i.properties->>'%s', '')", id), true, nil
|
|
default: // multi_select, checkbox, future types: no meaningful order
|
|
return "", true, nil
|
|
}
|
|
}
|
|
|
|
// selectPropertySortExpr ranks a select property's stored value by its
|
|
// position in the definition's option list, so an ordinal scale (Low < Medium
|
|
// < High) sorts by meaning rather than by the option-id string. A stored value
|
|
// no longer in the config — and an issue without the property — yields NULL,
|
|
// which the callers order last.
|
|
//
|
|
// Each CASE arm embeds the option id's ORIGINAL config spelling: explicit ids
|
|
// are stored as supplied (validatePropertyConfig trims but does not
|
|
// re-serialize), and issue values must equal that spelling exactly, so a
|
|
// canonicalized form would never match. Embedding is inert because uuid.Parse
|
|
// gates every arm and its accepted grammar (hex, dashes, braces, urn:uuid:
|
|
// prefix) admits no quote or backslash. An empty or malformed config — the API
|
|
// enforces at least one option, so only a corrupt row — degrades to "" and the
|
|
// caller keeps position order, like an unknown definition.
|
|
func selectPropertySortExpr(defID string, cfg PropertyConfig) string {
|
|
var b strings.Builder
|
|
rank := 0
|
|
for _, opt := range cfg.Options {
|
|
if _, err := uuid.Parse(opt.ID); err != nil {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, " WHEN '%s' THEN %d", opt.ID, rank)
|
|
rank++
|
|
}
|
|
if rank == 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("(CASE i.properties->>'%s'%s END)::numeric", defID, b.String())
|
|
}
|