mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* 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]>
450 lines
14 KiB
Go
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)
|
|
}
|