Files
7c186016a5 feat(core): multica 1.0 part 1 (MUL-8317) (#9635)
* 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]>
2026-10-31 15:26:51 +08:00

466 lines
15 KiB
Go

package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// Saved issue views (MUL-4796): server-backed filter definitions with
// display defaults. `query` is the shared identity of a view; `display`
// only seeds a member's first open. Both are opaque JSON to the server —
// interpretation lives in the client definition_version contract.
const issueViewNameMaxLen = 80
// issueViewBodyMaxBytes bounds every saved-view write. The query/display
// blobs are filter documents — real ones are a few KB; the cap is the
// abuse backstop, comfortably above any legitimate payload.
const issueViewBodyMaxBytes = 128 * 1024
// issueViewsPerOwnerMax bounds table growth per member per workspace.
const issueViewsPerOwnerMax = 100
var (
validIssueViewScopeTypes = []string{"workspace", "my", "project"}
validIssueViewMyVariants = []string{"assigned", "created", "involved", "any"}
validIssueViewWorkspaceVariants = []string{"members", "agents"}
validIssueViewVisibilities = []string{"private", "workspace"}
)
// validateIssueViewVariant returns the pgtype value for a scope_variant
// input under the given scope_type, or ok=false when the pairing is
// invalid. Workspace and project variants are optional assignee-type
// narrowing (NULL = the unrestricted All tab); my variants are required.
func validateIssueViewVariant(scopeType string, variant *string) (pgtype.Text, bool) {
switch scopeType {
case "my":
if variant == nil || !contains(validIssueViewMyVariants, *variant) {
return pgtype.Text{}, false
}
return pgtype.Text{String: *variant, Valid: true}, true
default: // workspace, project
if variant == nil || *variant == "" || *variant == "all" {
return pgtype.Text{}, true
}
if !contains(validIssueViewWorkspaceVariants, *variant) {
return pgtype.Text{}, false
}
return pgtype.Text{String: *variant, Valid: true}, true
}
}
type IssueViewResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
OwnerID string `json:"owner_id"`
Name string `json:"name"`
ScopeType string `json:"scope_type"`
ScopeID *string `json:"scope_id"`
ScopeVariant *string `json:"scope_variant"`
Visibility string `json:"visibility"`
DefinitionVersion int32 `json:"definition_version"`
Query json.RawMessage `json:"query"`
Display json.RawMessage `json:"display"`
Revision int32 `json:"revision"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func issueViewToResponse(v db.IssueView) IssueViewResponse {
return IssueViewResponse{
ID: uuidToString(v.ID),
WorkspaceID: uuidToString(v.WorkspaceID),
OwnerID: uuidToString(v.OwnerID),
Name: v.Name,
ScopeType: v.ScopeType,
ScopeID: uuidToPtr(v.ScopeID),
ScopeVariant: textToPtr(v.ScopeVariant),
Visibility: v.Visibility,
DefinitionVersion: v.DefinitionVersion,
Query: json.RawMessage(v.Query),
Display: json.RawMessage(v.Display),
Revision: v.Revision,
CreatedAt: timestampToString(v.CreatedAt),
UpdatedAt: timestampToString(v.UpdatedAt),
}
}
// canReadIssueView: owner always; workspace-shared views for any member.
// (My-scope views are constrained to private by the DB CHECK, so they only
// ever match the owner branch.)
func canReadIssueView(v db.IssueView, userID pgtype.UUID) bool {
return v.OwnerID == userID || v.Visibility == "workspace"
}
func isJSONObject(raw json.RawMessage) bool {
var m map[string]json.RawMessage
if json.Unmarshal(raw, &m) != nil {
return false
}
// JSON null unmarshals into a nil map without error — reject it here
// instead of letting the DB CHECK turn it into a 500.
return m != nil
}
type CreateIssueViewRequest struct {
Name string `json:"name"`
ScopeType string `json:"scope_type"`
ScopeID *string `json:"scope_id"`
ScopeVariant *string `json:"scope_variant"`
Visibility string `json:"visibility"`
DefinitionVersion int32 `json:"definition_version"`
Query json.RawMessage `json:"query"`
Display json.RawMessage `json:"display"`
}
func (h *Handler) CreateIssueView(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, h.resolveWorkspaceID(r), "workspace id")
if !ok {
return
}
var req CreateIssueViewRequest
r.Body = http.MaxBytesReader(w, r.Body, issueViewBodyMaxBytes)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if l := len([]rune(req.Name)); l < 1 || l > issueViewNameMaxLen {
writeError(w, http.StatusBadRequest, "name must be between 1 and 80 characters")
return
}
ownedCount, err := h.Queries.CountIssueViewsByOwner(r.Context(), db.CountIssueViewsByOwnerParams{
WorkspaceID: wsUUID,
OwnerID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to check view quota")
return
}
if ownedCount >= issueViewsPerOwnerMax {
writeError(w, http.StatusBadRequest, "view limit reached for this workspace")
return
}
if !contains(validIssueViewScopeTypes, req.ScopeType) {
writeError(w, http.StatusBadRequest, "invalid scope_type")
return
}
if req.Visibility == "" {
req.Visibility = "private"
}
if !contains(validIssueViewVisibilities, req.Visibility) {
writeError(w, http.StatusBadRequest, "invalid visibility")
return
}
if req.DefinitionVersion <= 0 {
req.DefinitionVersion = 1
}
if len(req.Query) == 0 || !isJSONObject(req.Query) {
writeError(w, http.StatusBadRequest, "query must be a JSON object")
return
}
if len(req.Display) == 0 {
req.Display = json.RawMessage("{}")
}
if !isJSONObject(req.Display) {
writeError(w, http.StatusBadRequest, "display must be a JSON object")
return
}
scopeVariant, variantOK := validateIssueViewVariant(req.ScopeType, req.ScopeVariant)
if !variantOK {
writeError(w, http.StatusBadRequest, "invalid scope_variant for this scope_type")
return
}
var scopeID pgtype.UUID
switch req.ScopeType {
case "project":
if req.ScopeID == nil {
writeError(w, http.StatusBadRequest, "scope_id is required for project views")
return
}
projUUID, ok := parseUUIDOrBadRequest(w, *req.ScopeID, "scope_id")
if !ok {
return
}
// The project must exist in this workspace — a view on a foreign or
// deleted project would be unreachable and could leak across tenants.
if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
ID: projUUID, WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusNotFound, "project not found")
return
}
scopeID = projUUID
case "my":
// My Issues is a per-user perspective; sharing it is meaningless.
req.Visibility = "private"
}
view, err := h.Queries.CreateIssueView(r.Context(), db.CreateIssueViewParams{
WorkspaceID: wsUUID,
OwnerID: parseUUID(userID),
Name: req.Name,
ScopeType: req.ScopeType,
ScopeID: scopeID,
ScopeVariant: scopeVariant,
Visibility: req.Visibility,
DefinitionVersion: req.DefinitionVersion,
Query: req.Query,
Display: req.Display,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create view")
return
}
writeJSON(w, http.StatusCreated, issueViewToResponse(view))
}
func (h *Handler) ListIssueViews(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, h.resolveWorkspaceID(r), "workspace id")
if !ok {
return
}
scopeType := r.URL.Query().Get("scope_type")
if scopeType == "all" {
views, err := h.Queries.ListAllIssueViewsForUser(r.Context(), db.ListAllIssueViewsForUserParams{
WorkspaceID: wsUUID,
OwnerID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list views")
return
}
resp := make([]IssueViewResponse, len(views))
for i, v := range views {
resp[i] = issueViewToResponse(v)
}
writeJSON(w, http.StatusOK, resp)
return
}
if !contains(validIssueViewScopeTypes, scopeType) {
writeError(w, http.StatusBadRequest, "invalid scope_type")
return
}
var scopeID pgtype.UUID
if raw := r.URL.Query().Get("scope_id"); raw != "" {
id, ok := parseUUIDOrBadRequest(w, raw, "scope_id")
if !ok {
return
}
scopeID = id
}
views, err := h.Queries.ListIssueViewsForUser(r.Context(), db.ListIssueViewsForUserParams{
WorkspaceID: wsUUID,
ScopeType: scopeType,
OwnerID: parseUUID(userID),
ScopeID: scopeID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list views")
return
}
resp := make([]IssueViewResponse, len(views))
for i, v := range views {
resp[i] = issueViewToResponse(v)
}
writeJSON(w, http.StatusOK, resp)
}
// loadIssueViewForUser resolves the id, enforces workspace scoping, and
// applies read authorization. Unauthorized and missing are both 404 so a
// private view's existence never leaks.
func (h *Handler) loadIssueViewForUser(w http.ResponseWriter, r *http.Request, userID string) (db.IssueView, pgtype.UUID, bool) {
wsUUID, ok := parseUUIDOrBadRequest(w, h.resolveWorkspaceID(r), "workspace id")
if !ok {
return db.IssueView{}, pgtype.UUID{}, false
}
idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "view id")
if !ok {
return db.IssueView{}, pgtype.UUID{}, false
}
view, err := h.Queries.GetIssueView(r.Context(), db.GetIssueViewParams{
ID: idUUID, WorkspaceID: wsUUID,
})
if err != nil || !canReadIssueView(view, parseUUID(userID)) {
writeError(w, http.StatusNotFound, "view not found")
return db.IssueView{}, pgtype.UUID{}, false
}
return view, wsUUID, true
}
func (h *Handler) GetIssueViewByID(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
view, _, ok := h.loadIssueViewForUser(w, r, userID)
if !ok {
return
}
writeJSON(w, http.StatusOK, issueViewToResponse(view))
}
// canManageIssueView: the owner, or a workspace admin/owner for shared
// views. Private views of others are invisible (404 in the loader), so
// admin powers never reach them.
func (h *Handler) canManageIssueView(r *http.Request, view db.IssueView, userID string) bool {
if view.OwnerID == parseUUID(userID) {
return true
}
if view.Visibility != "workspace" {
return false
}
member, err := h.Queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
UserID: parseUUID(userID),
WorkspaceID: view.WorkspaceID,
})
if err != nil {
return false
}
return roleAllowed(member.Role, "owner", "admin")
}
type UpdateIssueViewRequest struct {
Name *string `json:"name"`
Visibility *string `json:"visibility"`
ScopeVariant *string `json:"scope_variant"`
Query json.RawMessage `json:"query"`
Display json.RawMessage `json:"display"`
ExpectedRevision int32 `json:"expected_revision"`
}
func (h *Handler) UpdateIssueView(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
view, wsUUID, ok := h.loadIssueViewForUser(w, r, userID)
if !ok {
return
}
if !h.canManageIssueView(r, view, userID) {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
var req UpdateIssueViewRequest
r.Body = http.MaxBytesReader(w, r.Body, issueViewBodyMaxBytes)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ExpectedRevision <= 0 {
writeError(w, http.StatusBadRequest, "expected_revision is required")
return
}
name := view.Name
if req.Name != nil {
if l := len([]rune(*req.Name)); l < 1 || l > issueViewNameMaxLen {
writeError(w, http.StatusBadRequest, "name must be between 1 and 80 characters")
return
}
name = *req.Name
}
visibility := view.Visibility
if req.Visibility != nil {
if !contains(validIssueViewVisibilities, *req.Visibility) {
writeError(w, http.StatusBadRequest, "invalid visibility")
return
}
if view.ScopeType == "my" && *req.Visibility != "private" {
writeError(w, http.StatusBadRequest, "my views are always private")
return
}
visibility = *req.Visibility
}
query := json.RawMessage(view.Query)
if len(req.Query) > 0 {
if !isJSONObject(req.Query) {
writeError(w, http.StatusBadRequest, "query must be a JSON object")
return
}
query = req.Query
}
display := json.RawMessage(view.Display)
if len(req.Display) > 0 {
if !isJSONObject(req.Display) {
writeError(w, http.StatusBadRequest, "display must be a JSON object")
return
}
display = req.Display
}
// scope_type itself is immutable; the variant within it may switch.
scopeVariant := view.ScopeVariant
if req.ScopeVariant != nil {
next, variantOK := validateIssueViewVariant(view.ScopeType, req.ScopeVariant)
if !variantOK {
writeError(w, http.StatusBadRequest, "invalid scope_variant for this scope_type")
return
}
scopeVariant = next
}
updated, err := h.Queries.UpdateIssueView(r.Context(), db.UpdateIssueViewParams{
ID: view.ID,
WorkspaceID: wsUUID,
Name: name,
Visibility: visibility,
ScopeVariant: scopeVariant,
Query: query,
Display: display,
Revision: req.ExpectedRevision,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// The row exists (we just loaded it) — zero rows means the
// revision moved underneath the caller.
writeError(w, http.StatusConflict, "view was modified by someone else")
return
}
writeError(w, http.StatusInternalServerError, "failed to update view")
return
}
writeJSON(w, http.StatusOK, issueViewToResponse(updated))
}
func (h *Handler) DeleteIssueView(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
view, wsUUID, ok := h.loadIssueViewForUser(w, r, userID)
if !ok {
return
}
if !h.canManageIssueView(r, view, userID) {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
if _, err := h.Queries.DeleteIssueView(r.Context(), db.DeleteIssueViewParams{
ID: view.ID, WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete view")
return
}
w.WriteHeader(http.StatusNoContent)
}