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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Post-review pass on the convergence refactor:

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

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

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

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

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

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

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

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

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

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

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

* feat(issues): filter chips ease in

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

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

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

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

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

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

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

Two data-loss paths from review, plus hardening:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses the pre-ship review blockers:

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

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

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

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

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

---------

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

450 lines
14 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 !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)
}