From 98c5c05574c2dbc90904d08fa1698521d8b58c69 Mon Sep 17 00:00:00 2001 From: Vitor Hervatin <54643926+vhervatin@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:35:05 -0300 Subject: [PATCH 1/4] fix(views): make view settings and filters per-user instead of global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View settings and filters (sort, field sum, page size, visible fields, collapsed columns, and every filter dimension) were stored in the single shared `config` JSONB column of the `sprint_views` row, keyed only by project + context. Any member's change to the "View settings" panel therefore overwrote that shared row and — reinforced by the project-wide `view.updated` broadcast — changed what every other member saw. Views remain shared project entities (name, type, position, and a shared default config), but personal settings/filters now live in a new `user_view_configs` table keyed by (view_id, user_id) and are overlaid on read. Writes from the settings panel target the current user's override via a new `PUT /projects/{projectId}/views/{viewId}/config` endpoint and emit no project-wide event, so a member's tweaks stay private. A viewer (read-only member) can personalize their own view since the endpoint only requires sprints:read. The overlay runs after the shared view cache is read, so the cache never holds one user's config; unauthenticated readers of public projects fall back to the shared default. Co-Authored-By: Claude Opus 4.8 --- .../interactions/interaction-layout.tsx | 6 +- apps/web/src/lib/interaction-api.ts | 19 ++ .../api/internal/domain/sprint/repository.go | 9 + .../api/internal/domain/sprint/service.go | 12 ++ .../repository/postgres/view_repository.go | 66 +++++++ .../service/sprint/cached_service_test.go | 8 + .../service/sprint/cached_view_service.go | 15 ++ .../internal/service/sprint/view_service.go | 57 ++++++ .../service/sprint/view_service_test.go | 172 +++++++++++++++++- .../internal/transport/http/dto/view_dto.go | 12 ++ .../http/handler/sprint_handler_test.go | 8 + .../http/handler/task_handler_test.go | 8 + .../transport/http/handler/view_handler.go | 49 +++++ .../internal/transport/http/router/router.go | 5 + .../000042_add_user_view_configs.sql | 30 +++ 15 files changed, 470 insertions(+), 6 deletions(-) create mode 100644 services/api/migrations/000042_add_user_view_configs.sql diff --git a/apps/web/src/components/projects/interactions/interaction-layout.tsx b/apps/web/src/components/projects/interactions/interaction-layout.tsx index 5de2d6f2c..bfc769681 100644 --- a/apps/web/src/components/projects/interactions/interaction-layout.tsx +++ b/apps/web/src/components/projects/interactions/interaction-layout.tsx @@ -68,6 +68,7 @@ import { type Task, type TaskListResult, taskQueryOptions, + updateMyViewConfig, updateSprint, updateTask, updateViewById, @@ -1569,9 +1570,12 @@ export function InteractionLayout({ onSuccess: () => qc.invalidateQueries({ queryKey: viewsQueryKey }), }); + // View settings & filters are PER-USER: persist to the current user's + // personal override (updateMyViewConfig) instead of the shared view row, so + // one member's sort/filter/field choices never change what others see. const updateViewConfigMutation = useMutation({ mutationFn: (payload: { viewId: string; config: ViewConfig }) => - updateViewById(projectId, payload.viewId, { config: payload.config }), + updateMyViewConfig(projectId, payload.viewId, payload.config), onSuccess: () => { setPreviewConfig(undefined); qc.invalidateQueries({ queryKey: viewsQueryKey }); diff --git a/apps/web/src/lib/interaction-api.ts b/apps/web/src/lib/interaction-api.ts index 0878029e9..0bd77eefe 100644 --- a/apps/web/src/lib/interaction-api.ts +++ b/apps/web/src/lib/interaction-api.ts @@ -334,6 +334,25 @@ export async function updateViewById( return mapView(data.data); } +/** + * Persist the current user's PERSONAL view config (settings & filters). + * + * Unlike {@link updateViewById}, which mutates the project-shared view row, + * this targets a per-user override so a member's sort/filter/field choices + * never leak to other members. Reads (list/get views) already return the + * caller's effective config, so the shape is identical to a shared view. + */ +export async function updateMyViewConfig( + projectId: string, + viewId: string, + config: ViewConfig, +): Promise { + const { data } = await apiClient.instance.put< + SuccessEnvelope> + >(`/projects/${projectId}/views/${viewId}/config`, { config }); + return mapView(data.data); +} + export async function deleteViewById( projectId: string, viewId: string, diff --git a/services/api/internal/domain/sprint/repository.go b/services/api/internal/domain/sprint/repository.go index 5d5ed4252..540bc6a77 100644 --- a/services/api/internal/domain/sprint/repository.go +++ b/services/api/internal/domain/sprint/repository.go @@ -53,6 +53,15 @@ type ViewRepository interface { // ReorderViews bulk-updates the position of multiple views in a single // transaction. items must contain one entry per view being repositioned. ReorderViews(ctx context.Context, items []ViewReorderItem) error + + // GetUserViewConfigs returns a user's personal config overrides for the + // given views, keyed by view ID. Views without an override are absent + // from the returned map. + GetUserViewConfigs(ctx context.Context, userID uuid.UUID, viewIDs []uuid.UUID) (map[uuid.UUID]ViewConfig, error) + + // UpsertUserViewConfig stores (or replaces) a user's personal config for a + // single view. + UpsertUserViewConfig(ctx context.Context, viewID, userID uuid.UUID, cfg ViewConfig) error } // ViewReorderItem carries the new position for a single view. diff --git a/services/api/internal/domain/sprint/service.go b/services/api/internal/domain/sprint/service.go index e3b4d93ee..c2fcd3670 100644 --- a/services/api/internal/domain/sprint/service.go +++ b/services/api/internal/domain/sprint/service.go @@ -90,6 +90,18 @@ type ViewService interface { // context. viewIDs must contain every view ID for that project+context // in the desired order. ReorderProjectViews(ctx context.Context, projectID uuid.UUID, viewCtx ViewContext, viewIDs []uuid.UUID) error + + // SetUserViewConfig stores the current user's personal config (settings and + // filters) for a view, verifying it belongs to projectID, and returns the + // view carrying that personal config. The change is private to the user: + // it never touches the shared view row and emits no project-wide event. + SetUserViewConfig(ctx context.Context, projectID, viewID, userID uuid.UUID, cfg ViewConfig) (*SprintView, error) + + // OverlayUserConfigs replaces each view's Config with the user's personal + // override where one exists, leaving the shared default otherwise. It is + // safe to call with views obtained from a cache: only the returned copies + // are mutated. A nil user (unauthenticated) is a no-op. + OverlayUserConfigs(ctx context.Context, userID uuid.UUID, views []*SprintView) error } // CreateViewInput carries fields required to create a sprint view. diff --git a/services/api/internal/repository/postgres/view_repository.go b/services/api/internal/repository/postgres/view_repository.go index 858529a01..ba02df354 100644 --- a/services/api/internal/repository/postgres/view_repository.go +++ b/services/api/internal/repository/postgres/view_repository.go @@ -242,6 +242,72 @@ func (r *ViewRepository) ListTaskPositions(ctx context.Context, viewID uuid.UUID return out, nil } +// --- Per-user view config methods ------------------------------------------- + +// userViewConfigRecord scans a row of the user_view_configs table. +type userViewConfigRecord struct { + ViewID string `db:"view_id"` + Config []byte `db:"config"` +} + +// GetUserViewConfigs returns the given user's personal config overrides for the +// provided view IDs, keyed by view ID. Views without an override are omitted. +func (r *ViewRepository) GetUserViewConfigs(ctx context.Context, userID uuid.UUID, viewIDs []uuid.UUID) (map[uuid.UUID]sprintdom.ViewConfig, error) { + out := make(map[uuid.UUID]sprintdom.ViewConfig, len(viewIDs)) + if len(viewIDs) == 0 { + return out, nil + } + ids := make([]string, len(viewIDs)) + for i, id := range viewIDs { + ids[i] = id.String() + } + query, args, err := sqlx.In( + `SELECT view_id, config FROM user_view_configs WHERE user_id = ? AND view_id IN (?)`, + userID.String(), ids, + ) + if err != nil { + return nil, fmt.Errorf("view repo: build get user view configs: %w", err) + } + query = r.db.Rebind(query) + var records []userViewConfigRecord + if err := r.db.SelectContext(ctx, &records, query, args...); err != nil { + return nil, fmt.Errorf("view repo: get user view configs: %w", err) + } + for i := range records { + vid, err := uuid.Parse(records[i].ViewID) + if err != nil { + return nil, fmt.Errorf("view repo: parse user view config id: %w", err) + } + var cfg sprintdom.ViewConfig + if len(records[i].Config) > 0 { + if err := json.Unmarshal(records[i].Config, &cfg); err != nil { + return nil, fmt.Errorf("view repo: unmarshal user view config: %w", err) + } + } + out[vid] = cfg + } + return out, nil +} + +// UpsertUserViewConfig stores or replaces a user's personal config for a view. +func (r *ViewRepository) UpsertUserViewConfig(ctx context.Context, viewID, userID uuid.UUID, cfg sprintdom.ViewConfig) error { + configBytes, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("view repo: marshal user view config: %w", err) + } + _, err = r.db.ExecContext(ctx, ` + INSERT INTO user_view_configs (view_id, user_id, config, created_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (view_id, user_id) DO UPDATE + SET config = EXCLUDED.config, updated_at = NOW()`, + viewID.String(), userID.String(), configBytes, + ) + if err != nil { + return fmt.Errorf("view repo: upsert user view config: %w", err) + } + return nil +} + // --- Entity converters ------------------------------------------------------ func toViewEntity(r *sprintViewRecord) (*sprintdom.SprintView, error) { diff --git a/services/api/internal/service/sprint/cached_service_test.go b/services/api/internal/service/sprint/cached_service_test.go index 802bf655b..784c813eb 100644 --- a/services/api/internal/service/sprint/cached_service_test.go +++ b/services/api/internal/service/sprint/cached_service_test.go @@ -426,6 +426,14 @@ func (s *stubViewSvc) ReorderProjectViews(ctx context.Context, projectID uuid.UU return nil } +func (s *stubViewSvc) SetUserViewConfig(_ context.Context, _, _, _ uuid.UUID, _ sprintdom.ViewConfig) (*sprintdom.SprintView, error) { + return nil, nil +} + +func (s *stubViewSvc) OverlayUserConfigs(_ context.Context, _ uuid.UUID, _ []*sprintdom.SprintView) error { + return nil +} + // --------------------------------------------------------------------------- // CachedViewService – ListProjectViews // --------------------------------------------------------------------------- diff --git a/services/api/internal/service/sprint/cached_view_service.go b/services/api/internal/service/sprint/cached_view_service.go index 1933dfe62..81346582f 100644 --- a/services/api/internal/service/sprint/cached_view_service.go +++ b/services/api/internal/service/sprint/cached_view_service.go @@ -196,3 +196,18 @@ func (c *CachedViewService) ReorderProjectViews(ctx context.Context, projectID u } return nil } + +// SetUserViewConfig delegates directly to the underlying service. Personal +// configs are never stored in the shared cache, so no invalidation is needed: +// the cached ListProjectViews/GetView entries hold the shared view definition +// and are overlaid with the caller's personal config after the cache is read. +func (c *CachedViewService) SetUserViewConfig(ctx context.Context, projectID, viewID, userID uuid.UUID, cfg sprintdom.ViewConfig) (*sprintdom.SprintView, error) { + return c.svc.SetUserViewConfig(ctx, projectID, viewID, userID, cfg) +} + +// OverlayUserConfigs delegates directly to the underlying service. It must run +// after the cache is read so the shared cached entries are never polluted with +// one user's personal config. +func (c *CachedViewService) OverlayUserConfigs(ctx context.Context, userID uuid.UUID, views []*sprintdom.SprintView) error { + return c.svc.OverlayUserConfigs(ctx, userID, views) +} diff --git a/services/api/internal/service/sprint/view_service.go b/services/api/internal/service/sprint/view_service.go index bfe27e8d3..787742944 100644 --- a/services/api/internal/service/sprint/view_service.go +++ b/services/api/internal/service/sprint/view_service.go @@ -303,6 +303,63 @@ func (s *ViewService) ReorderProjectViews(ctx context.Context, projectID uuid.UU return nil } +// SetUserViewConfig stores the current user's personal config for a view, +// verifying it belongs to projectID, and returns the view carrying that config. +// The write is private to the user: the shared sprint_views row is untouched +// and no project-wide real-time event is published, so other members are +// unaffected. +func (s *ViewService) SetUserViewConfig(ctx context.Context, projectID, viewID, userID uuid.UUID, cfg sprintdom.ViewConfig) (*sprintdom.SprintView, error) { + v, err := s.repo.FindViewByID(ctx, viewID) + if err != nil { + return nil, err + } + if v.ProjectID != projectID { + return nil, sprintdom.ErrViewNotFound + } + // A plugin view still needs its plugin binding in the personal config. + if !hasPluginConfig(v.ViewType, &cfg) { + return nil, sprintdom.ErrViewPluginConfigRequired + } + if err := s.repo.UpsertUserViewConfig(ctx, viewID, userID, cfg); err != nil { + return nil, err + } + v.Config = cfg + return v, nil +} + +// OverlayUserConfigs replaces each view's Config with the user's personal +// override where one exists, leaving the shared default otherwise. A nil user +// or empty view list is a no-op. The passed views are mutated in place; callers +// pass per-request copies (cache hits deserialize fresh objects), so the shared +// cache is never affected. +func (s *ViewService) OverlayUserConfigs(ctx context.Context, userID uuid.UUID, views []*sprintdom.SprintView) error { + if userID == uuid.Nil || len(views) == 0 { + return nil + } + ids := make([]uuid.UUID, 0, len(views)) + for _, v := range views { + if v != nil { + ids = append(ids, v.ID) + } + } + overrides, err := s.repo.GetUserViewConfigs(ctx, userID, ids) + if err != nil { + return err + } + if len(overrides) == 0 { + return nil + } + for _, v := range views { + if v == nil { + continue + } + if cfg, ok := overrides[v.ID]; ok { + v.Config = cfg + } + } + return nil +} + // validateAndReorder checks that viewIDs exactly matches the IDs of existing // views (same count, no unknowns) then persists the new positions. func (s *ViewService) validateAndReorder(ctx context.Context, existing []*sprintdom.SprintView, viewIDs []uuid.UUID) error { diff --git a/services/api/internal/service/sprint/view_service_test.go b/services/api/internal/service/sprint/view_service_test.go index 8456b0831..c62390c8b 100644 --- a/services/api/internal/service/sprint/view_service_test.go +++ b/services/api/internal/service/sprint/view_service_test.go @@ -17,18 +17,43 @@ import ( // --------------------------------------------------------------------------- type fakeViewRepo struct { - mu sync.RWMutex - views map[uuid.UUID]*sprintdom.SprintView - positions map[string]*sprintdom.ViewTaskPosition // key: viewID+":"+taskID + mu sync.RWMutex + views map[uuid.UUID]*sprintdom.SprintView + positions map[string]*sprintdom.ViewTaskPosition // key: viewID+":"+taskID + userConfigs map[string]sprintdom.ViewConfig // key: viewID+":"+userID } func newFakeViewRepo() *fakeViewRepo { return &fakeViewRepo{ - views: make(map[uuid.UUID]*sprintdom.SprintView), - positions: make(map[string]*sprintdom.ViewTaskPosition), + views: make(map[uuid.UUID]*sprintdom.SprintView), + positions: make(map[string]*sprintdom.ViewTaskPosition), + userConfigs: make(map[string]sprintdom.ViewConfig), } } +func userCfgKey(viewID, userID uuid.UUID) string { + return viewID.String() + ":" + userID.String() +} + +func (r *fakeViewRepo) GetUserViewConfigs(_ context.Context, userID uuid.UUID, viewIDs []uuid.UUID) (map[uuid.UUID]sprintdom.ViewConfig, error) { + r.mu.RLock() + defer r.mu.RUnlock() + out := make(map[uuid.UUID]sprintdom.ViewConfig) + for _, vid := range viewIDs { + if cfg, ok := r.userConfigs[userCfgKey(vid, userID)]; ok { + out[vid] = cfg + } + } + return out, nil +} + +func (r *fakeViewRepo) UpsertUserViewConfig(_ context.Context, viewID, userID uuid.UUID, cfg sprintdom.ViewConfig) error { + r.mu.Lock() + defer r.mu.Unlock() + r.userConfigs[userCfgKey(viewID, userID)] = cfg + return nil +} + func posKey(viewID, taskID uuid.UUID) string { return viewID.String() + ":" + taskID.String() } @@ -1048,3 +1073,140 @@ func TestViewService_ViewContextPreservedAfterUpdate(t *testing.T) { t.Errorf("ViewContext changed after update: got %q", updated.ViewContext) } } + +// --------------------------------------------------------------------------- +// Per-user view config (settings/filters must not leak across users) +// --------------------------------------------------------------------------- + +// seedProjectView creates a project-scoped view with the given shared config and +// returns it. Fails the test on error. +func seedProjectView(t *testing.T, svc *sprintsvc.ViewService, projectID uuid.UUID, shared sprintdom.ViewConfig) *sprintdom.SprintView { + t.Helper() + v, err := svc.CreateView(context.Background(), sprintdom.CreateViewInput{ + ProjectID: projectID, + Name: "Table", + ViewType: sprintdom.ViewTypeTable, + Config: shared, + ViewContext: sprintdom.ViewContextBacklog, + }) + if err != nil { + t.Fatalf("seed view: %v", err) + } + return v +} + +func TestViewService_SetUserViewConfig_StoresPerUserAndReturnsIt(t *testing.T) { + ctx := context.Background() + repo := newFakeViewRepo() + svc := sprintsvc.NewViewService(repo, nil) + + projectID := uuid.New() + view := seedProjectView(t, svc, projectID, sprintdom.ViewConfig{SortBy: "created"}) + userA := uuid.New() + + personal := sprintdom.ViewConfig{SortBy: "importance"} + got, err := svc.SetUserViewConfig(ctx, projectID, view.ID, userA, personal) + if err != nil { + t.Fatalf("SetUserViewConfig: %v", err) + } + if got.Config.SortBy != "importance" { + t.Errorf("returned view config = %q, want %q", got.Config.SortBy, "importance") + } + + // The shared row must be untouched. + shared, err := repo.FindViewByID(ctx, view.ID) + if err != nil { + t.Fatalf("FindViewByID: %v", err) + } + if shared.Config.SortBy != "created" { + t.Errorf("shared view config leaked: got %q, want %q", shared.Config.SortBy, "created") + } + + // Stored under (view, userA); a different user has no override. + forA, _ := repo.GetUserViewConfigs(ctx, userA, []uuid.UUID{view.ID}) + if cfg, ok := forA[view.ID]; !ok || cfg.SortBy != "importance" { + t.Errorf("userA override not stored: %+v (ok=%v)", cfg, ok) + } + forB, _ := repo.GetUserViewConfigs(ctx, uuid.New(), []uuid.UUID{view.ID}) + if _, ok := forB[view.ID]; ok { + t.Errorf("another user unexpectedly has an override") + } +} + +func TestViewService_SetUserViewConfig_WrongProjectReturnsNotFound(t *testing.T) { + ctx := context.Background() + repo := newFakeViewRepo() + svc := sprintsvc.NewViewService(repo, nil) + + view := seedProjectView(t, svc, uuid.New(), sprintdom.ViewConfig{}) + _, err := svc.SetUserViewConfig(ctx, uuid.New() /* wrong project */, view.ID, uuid.New(), sprintdom.ViewConfig{}) + if err != sprintdom.ErrViewNotFound { + t.Errorf("expected ErrViewNotFound, got %v", err) + } +} + +func TestViewService_OverlayUserConfigs_AppliesOverrideAndKeepsSharedElsewhere(t *testing.T) { + ctx := context.Background() + repo := newFakeViewRepo() + svc := sprintsvc.NewViewService(repo, nil) + + projectID := uuid.New() + v1 := seedProjectView(t, svc, projectID, sprintdom.ViewConfig{SortBy: "created"}) + v2 := seedProjectView(t, svc, projectID, sprintdom.ViewConfig{SortBy: "created"}) + userA := uuid.New() + + if _, err := svc.SetUserViewConfig(ctx, projectID, v1.ID, userA, sprintdom.ViewConfig{SortBy: "importance"}); err != nil { + t.Fatalf("SetUserViewConfig: %v", err) + } + + // Simulate the shared views coming back from the (shared) cache/list. + views := []*sprintdom.SprintView{ + {ID: v1.ID, ProjectID: projectID, Config: sprintdom.ViewConfig{SortBy: "created"}}, + {ID: v2.ID, ProjectID: projectID, Config: sprintdom.ViewConfig{SortBy: "created"}}, + } + if err := svc.OverlayUserConfigs(ctx, userA, views); err != nil { + t.Fatalf("OverlayUserConfigs: %v", err) + } + if views[0].Config.SortBy != "importance" { + t.Errorf("v1 not overlaid with personal config: got %q", views[0].Config.SortBy) + } + if views[1].Config.SortBy != "created" { + t.Errorf("v2 shared config changed: got %q, want %q", views[1].Config.SortBy, "created") + } +} + +func TestViewService_OverlayUserConfigs_OtherUserSeesSharedNotYours(t *testing.T) { + ctx := context.Background() + repo := newFakeViewRepo() + svc := sprintsvc.NewViewService(repo, nil) + + projectID := uuid.New() + v := seedProjectView(t, svc, projectID, sprintdom.ViewConfig{SortBy: "created"}) + userA, userB := uuid.New(), uuid.New() + + if _, err := svc.SetUserViewConfig(ctx, projectID, v.ID, userA, sprintdom.ViewConfig{SortBy: "importance"}); err != nil { + t.Fatalf("SetUserViewConfig: %v", err) + } + + // userB must still see the shared default — this is the bug being fixed. + views := []*sprintdom.SprintView{{ID: v.ID, ProjectID: projectID, Config: sprintdom.ViewConfig{SortBy: "created"}}} + if err := svc.OverlayUserConfigs(ctx, userB, views); err != nil { + t.Fatalf("OverlayUserConfigs: %v", err) + } + if views[0].Config.SortBy != "created" { + t.Errorf("userA's personal config leaked to userB: got %q, want %q", views[0].Config.SortBy, "created") + } +} + +func TestViewService_OverlayUserConfigs_NilUserIsNoop(t *testing.T) { + ctx := context.Background() + svc := sprintsvc.NewViewService(newFakeViewRepo(), nil) + + views := []*sprintdom.SprintView{{ID: uuid.New(), Config: sprintdom.ViewConfig{SortBy: "created"}}} + if err := svc.OverlayUserConfigs(ctx, uuid.Nil, views); err != nil { + t.Fatalf("OverlayUserConfigs: %v", err) + } + if views[0].Config.SortBy != "created" { + t.Errorf("nil user should be a no-op, got %q", views[0].Config.SortBy) + } +} diff --git a/services/api/internal/transport/http/dto/view_dto.go b/services/api/internal/transport/http/dto/view_dto.go index 841797ddf..c7bace061 100644 --- a/services/api/internal/transport/http/dto/view_dto.go +++ b/services/api/internal/transport/http/dto/view_dto.go @@ -26,6 +26,18 @@ type UpdateViewRequest struct { Position *float64 `json:"position"` } +// UpdateUserViewConfigRequest is the body for PUT +// /projects/:projectId/views/:viewId/config, which stores the current user's +// personal view config (settings and filters) without touching the shared view. +type UpdateUserViewConfigRequest struct { + Config *ViewConfigDTO `json:"config"` +} + +// ToViewConfig maps the request to a domain ViewConfig (empty when omitted). +func (r UpdateUserViewConfigRequest) ToViewConfig() sprintdom.ViewConfig { + return toViewConfig(r.Config) +} + // ViewFiltersDTO is the JSON representation of sprintdom.ViewFilters. // Each dimension is an optional FilterConfig selector that the client uses to // determine which entity IDs to include when querying tasks. diff --git a/services/api/internal/transport/http/handler/sprint_handler_test.go b/services/api/internal/transport/http/handler/sprint_handler_test.go index 41bc81753..98327da9c 100644 --- a/services/api/internal/transport/http/handler/sprint_handler_test.go +++ b/services/api/internal/transport/http/handler/sprint_handler_test.go @@ -123,6 +123,14 @@ func (f *fakeViewSvcH) ReorderProjectViews(_ context.Context, _ uuid.UUID, _ spr return nil } +func (f *fakeViewSvcH) SetUserViewConfig(_ context.Context, _, _, _ uuid.UUID, _ sprintdom.ViewConfig) (*sprintdom.SprintView, error) { + return nil, nil +} + +func (f *fakeViewSvcH) OverlayUserConfigs(_ context.Context, _ uuid.UUID, _ []*sprintdom.SprintView) error { + return nil +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/services/api/internal/transport/http/handler/task_handler_test.go b/services/api/internal/transport/http/handler/task_handler_test.go index 59ec761f5..e2ad1c899 100644 --- a/services/api/internal/transport/http/handler/task_handler_test.go +++ b/services/api/internal/transport/http/handler/task_handler_test.go @@ -457,6 +457,14 @@ func (f *fakeViewSvcTask) ReorderProjectViews(_ context.Context, _ uuid.UUID, _ return nil } +func (f *fakeViewSvcTask) SetUserViewConfig(_ context.Context, _, _, _ uuid.UUID, _ sprintdom.ViewConfig) (*sprintdom.SprintView, error) { + return nil, nil +} + +func (f *fakeViewSvcTask) OverlayUserConfigs(_ context.Context, _ uuid.UUID, _ []*sprintdom.SprintView) error { + return nil +} + // --------------------------------------------------------------------------- // Router helper // --------------------------------------------------------------------------- diff --git a/services/api/internal/transport/http/handler/view_handler.go b/services/api/internal/transport/http/handler/view_handler.go index fe00abdca..f4eef97c7 100644 --- a/services/api/internal/transport/http/handler/view_handler.go +++ b/services/api/internal/transport/http/handler/view_handler.go @@ -79,6 +79,15 @@ func (h *ViewHandler) ListViews(w http.ResponseWriter, r *http.Request) { presenter.Error(w, r, err) return } + // Overlay the current user's personal config so settings/filters stay + // per-user. Runs after the (shared) cache read; unauthenticated callers + // (public projects) fall through to the shared defaults. + if actorID, ok := middleware.ActorIDFromContext(r.Context()); ok { + if err := h.svc.OverlayUserConfigs(r.Context(), actorID, views); err != nil { + presenter.Error(w, r, err) + return + } + } resp := make([]dto.ViewResponse, 0, len(views)) for _, v := range views { resp = append(resp, dto.ViewFromEntity(v)) @@ -103,6 +112,12 @@ func (h *ViewHandler) GetView(w http.ResponseWriter, r *http.Request) { presenter.Error(w, r, err) return } + if actorID, ok := middleware.ActorIDFromContext(r.Context()); ok { + if err := h.svc.OverlayUserConfigs(r.Context(), actorID, []*sprintdom.SprintView{v}); err != nil { + presenter.Error(w, r, err) + return + } + } presenter.OK(w, r, dto.ViewFromEntity(v)) } @@ -175,6 +190,40 @@ func (h *ViewHandler) UpdateView(w http.ResponseWriter, r *http.Request) { presenter.OK(w, r, dto.ViewFromEntity(v)) } +// UpdateMyViewConfig handles PUT /projects/:projectId/views/:viewId/config. +// It stores the authenticated user's personal view config (settings and +// filters) without mutating the shared view, so the change never leaks to +// other project members. +func (h *ViewHandler) UpdateMyViewConfig(w http.ResponseWriter, r *http.Request) { + projectID, err := parseProjectID(r) + if err != nil { + presenter.Error(w, r, err) + return + } + viewID, err := parseViewID(r) + if err != nil { + presenter.Error(w, r, err) + return + } + actorID, ok := middleware.ActorIDFromContext(r.Context()) + if !ok || actorID == uuid.Nil { + presenter.Error(w, r, apierr.New(apierr.CodeUnauthenticated, "authentication required")) + return + } + + var req dto.UpdateUserViewConfigRequest + if !middleware.BindJSON(w, r, &req) { + return + } + + v, err := h.svc.SetUserViewConfig(r.Context(), projectID, viewID, actorID, req.ToViewConfig()) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.OK(w, r, dto.ViewFromEntity(v)) +} + // DeleteView handles DELETE /sprints/:sprintId/views/:viewId. func (h *ViewHandler) DeleteView(w http.ResponseWriter, r *http.Request) { projectID, err := parseProjectID(r) diff --git a/services/api/internal/transport/http/router/router.go b/services/api/internal/transport/http/router/router.go index ad11da33f..9b74bae7c 100644 --- a/services/api/internal/transport/http/router/router.go +++ b/services/api/internal/transport/http/router/router.go @@ -480,6 +480,11 @@ func New(deps Deps) http.Handler { )).Get("/{viewId}", deps.View.GetView) r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionSprintsWrite)). Patch("/{viewId}", deps.View.UpdateView) + // Personal (per-user) view config: only needs read access to + // the project's sprints — a viewer may sort/filter their own + // view without permission to mutate the shared view. + r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionSprintsRead)). + Put("/{viewId}/config", deps.View.UpdateMyViewConfig) r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionSprintsWrite)). Delete("/{viewId}", deps.View.DeleteView) r.With(httpmw.RequirePublicProjectOrPermissions(deps.ProjectVisibilitySvc, deps.Authorizer, diff --git a/services/api/migrations/000042_add_user_view_configs.sql b/services/api/migrations/000042_add_user_view_configs.sql new file mode 100644 index 000000000..fe2d6f2c7 --- /dev/null +++ b/services/api/migrations/000042_add_user_view_configs.sql @@ -0,0 +1,30 @@ +-- 000042_add_user_view_configs.sql +-- Per-user overrides for interaction view settings and filters. +-- +-- A sprint_views row holds the project-shared view definition (name, type, +-- position) plus a shared default config. Personal tweaks a member makes in +-- the "View settings" panel — sort, field sum, page size, visible fields, +-- collapsed columns and every filter dimension — must stay private to that +-- member instead of overwriting the shared row for everyone. Those personal +-- configs live here, keyed by (view_id, user_id), and are overlaid on read. +-- +-- Rows are removed automatically when either the view or the user is deleted. + +BEGIN; + +CREATE TABLE IF NOT EXISTS user_view_configs ( + view_id UUID NOT NULL REFERENCES sprint_views(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + config JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (view_id, user_id) +); + +-- Supports the ON DELETE CASCADE from users and per-user lookups that filter +-- by user_id (user_id is the trailing column of the primary key, so it is not +-- usable as a leading index on its own). +CREATE INDEX IF NOT EXISTS idx_user_view_configs_user_id + ON user_view_configs (user_id); + +COMMIT; From 914809ef4741c34352dca50f4ebe723c2b3afeee Mon Sep 17 00:00:00 2001 From: Vitor Hervatin <54643926+vhervatin@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:33:30 -0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(views):=20implementa=20fake=20repo=20de?= =?UTF-8?q?=20integra=C3=A7=C3=A3o=20e=20endurece=20valida=C3=A7=C3=A3o=20?= =?UTF-8?q?do=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test/integration: adiciona GetUserViewConfigs/UpsertUserViewConfig ao fakeViewRepoIT (faltava implementar a interface ViewRepository, quebrando o typecheck do golangci-lint e a suíte de testes) - handler: UpdateMyViewConfig rejeita config nulo/ausente (PUT substitui a config pessoal; um body sem config apagaria as preferências do usuário) - web: seed de filtros default grava no override do próprio usuário via updateMyViewConfig, nunca na linha compartilhada Co-Authored-By: Claude Opus 4.8 --- .../interactions/interaction-layout.tsx | 5 ++- .../transport/http/handler/view_handler.go | 6 ++++ services/api/test/integration/view_test.go | 35 ++++++++++++++++--- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/projects/interactions/interaction-layout.tsx b/apps/web/src/components/projects/interactions/interaction-layout.tsx index bfc769681..22aa114ec 100644 --- a/apps/web/src/components/projects/interactions/interaction-layout.tsx +++ b/apps/web/src/components/projects/interactions/interaction-layout.tsx @@ -474,7 +474,10 @@ export function InteractionLayout({ uninitializedViews.map((view) => { const config = buildDefaultViewConfig(view.layout, view.config); if (!config) return Promise.resolve(view); - return updateViewById(projectId, view.id, { config }); + // Per-user: default filters are derived from the current user's + // preferences, so seed them into this user's override — never the + // shared row (which would leak one user's defaults to everyone). + return updateMyViewConfig(projectId, view.id, config); }), ) .then(() => qc.invalidateQueries({ queryKey: viewsQueryKey })) diff --git a/services/api/internal/transport/http/handler/view_handler.go b/services/api/internal/transport/http/handler/view_handler.go index f4eef97c7..19b8f4267 100644 --- a/services/api/internal/transport/http/handler/view_handler.go +++ b/services/api/internal/transport/http/handler/view_handler.go @@ -215,6 +215,12 @@ func (h *ViewHandler) UpdateMyViewConfig(w http.ResponseWriter, r *http.Request) if !middleware.BindJSON(w, r, &req) { return } + // A PUT replaces the personal config, so require it explicitly: an omitted + // or null `config` would otherwise silently wipe the user's saved settings. + if req.Config == nil { + presenter.Error(w, r, apierr.New(apierr.CodeBadRequest, "config is required")) + return + } v, err := h.svc.SetUserViewConfig(r.Context(), projectID, viewID, actorID, req.ToViewConfig()) if err != nil { diff --git a/services/api/test/integration/view_test.go b/services/api/test/integration/view_test.go index 3de748f40..1982f8eea 100644 --- a/services/api/test/integration/view_test.go +++ b/services/api/test/integration/view_test.go @@ -31,15 +31,17 @@ import ( // --------------------------------------------------------------------------- type fakeViewRepoIT struct { - mu sync.RWMutex - views map[uuid.UUID]*sprintdom.SprintView - positions map[string]*sprintdom.ViewTaskPosition + mu sync.RWMutex + views map[uuid.UUID]*sprintdom.SprintView + positions map[string]*sprintdom.ViewTaskPosition + userConfigs map[string]sprintdom.ViewConfig } func newFakeViewRepoIT() *fakeViewRepoIT { return &fakeViewRepoIT{ - views: make(map[uuid.UUID]*sprintdom.SprintView), - positions: make(map[string]*sprintdom.ViewTaskPosition), + views: make(map[uuid.UUID]*sprintdom.SprintView), + positions: make(map[string]*sprintdom.ViewTaskPosition), + userConfigs: make(map[string]sprintdom.ViewConfig), } } @@ -47,6 +49,10 @@ func viewPosKey(viewID, taskID uuid.UUID) string { return viewID.String() + ":" + taskID.String() } +func userViewCfgKey(viewID, userID uuid.UUID) string { + return viewID.String() + ":" + userID.String() +} + func (r *fakeViewRepoIT) ListViews(_ context.Context, sprintID uuid.UUID) ([]*sprintdom.SprintView, error) { r.mu.RLock() defer r.mu.RUnlock() @@ -178,6 +184,25 @@ func (r *fakeViewRepoIT) ReorderViews(_ context.Context, items []sprintdom.ViewR return nil } +func (r *fakeViewRepoIT) GetUserViewConfigs(_ context.Context, userID uuid.UUID, viewIDs []uuid.UUID) (map[uuid.UUID]sprintdom.ViewConfig, error) { + r.mu.RLock() + defer r.mu.RUnlock() + out := make(map[uuid.UUID]sprintdom.ViewConfig) + for _, viewID := range viewIDs { + if cfg, ok := r.userConfigs[userViewCfgKey(viewID, userID)]; ok { + out[viewID] = cfg + } + } + return out, nil +} + +func (r *fakeViewRepoIT) UpsertUserViewConfig(_ context.Context, viewID, userID uuid.UUID, cfg sprintdom.ViewConfig) error { + r.mu.Lock() + defer r.mu.Unlock() + r.userConfigs[userViewCfgKey(viewID, userID)] = cfg + return nil +} + // --------------------------------------------------------------------------- // Router builder // --------------------------------------------------------------------------- From 28653fb6101d7c9dfc684e6b5c4e30ce1dc8b63b Mon Sep 17 00:00:00 2001 From: Vitor Hervatin <54643926+vhervatin@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:52:56 -0300 Subject: [PATCH 3/4] =?UTF-8?q?test(e2e):=20cria=20tasks=20reais=20nos=20t?= =?UTF-8?q?estes=20de=20posi=C3=A7=C3=A3o=20de=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MoveTask/BulkMoveTasks passaram a validar ownership da task (fix upstream 0cf5129d), então um task_id inexistente agora retorna 404. Os testes de posição de view usavam UUIDs fake e esperavam 204 — o que quebra ao mesclar o v0.14.1. Passam a criar tasks reais via API antes de mover/listar. Co-Authored-By: Claude Opus 4.8 --- services/api/test/e2e/view_management_test.go | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/services/api/test/e2e/view_management_test.go b/services/api/test/e2e/view_management_test.go index 515d0c40d..15e18297a 100644 --- a/services/api/test/e2e/view_management_test.go +++ b/services/api/test/e2e/view_management_test.go @@ -199,8 +199,9 @@ func TestE2ETaskPositionManagement(t *testing.T) { sprintID := createSprintViaAPI(t, env, client, token, projID, "Sprint for Positions") viewID := createViewViaAPI(t, env, client, token, projID, sprintID, "Position View", "table") - // Use a fixed task UUID (doesn't need to exist in DB for position tracking) - taskID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + // The task must exist and belong to the project: MoveTask/BulkMoveTasks + // verify task ownership, so a non-existent task_id now yields 404. + taskID := createTaskViaAPI(t, env, client, token, projID, "Position Task") t.Run("move_task", func(t *testing.T) { url := fmt.Sprintf("%s/api/v1/projects/%s/views/%s/task-positions/%s", @@ -439,7 +440,7 @@ func TestE2EBacklogTaskPositionManagement(t *testing.T) { projID := createProjectForTasksViaAPI(t, env, client, token) viewID := createBacklogViewViaAPI(t, env, client, token, projID, "Backlog Position View", "table") - taskID := "cccccccc-dddd-eeee-ffff-aaaaaaaaaaaa" + taskID := createTaskViaAPI(t, env, client, token, projID, "Backlog Position Task") t.Run("move_task", func(t *testing.T) { url := fmt.Sprintf("%s/api/v1/projects/%s/views/%s/task-positions/%s", @@ -521,10 +522,11 @@ func TestE2EBulkTaskPositionManagement(t *testing.T) { sprintID := createSprintViaAPI(t, env, client, token, projID, "Sprint for Bulk Positions") viewID := createViewViaAPI(t, env, client, token, projID, sprintID, "Bulk Position View", "table") - // Fixed UUIDs — do not need to exist as actual tasks for position tracking - task1 := "11111111-1111-1111-1111-111111111111" - task2 := "22222222-2222-2222-2222-222222222222" - task3 := "33333333-3333-3333-3333-333333333333" + // Tasks must exist and belong to the project — MoveTask/BulkMoveTasks verify + // task ownership. + task1 := createTaskViaAPI(t, env, client, token, projID, "Bulk Position Task 1") + task2 := createTaskViaAPI(t, env, client, token, projID, "Bulk Position Task 2") + task3 := createTaskViaAPI(t, env, client, token, projID, "Bulk Position Task 3") bulkURL := fmt.Sprintf("%s/api/v1/projects/%s/views/%s/task-positions", env.base, projID, viewID) @@ -615,8 +617,8 @@ func TestE2EBulkBacklogTaskPositionManagement(t *testing.T) { projID := createProjectForTasksViaAPI(t, env, client, token) viewID := createBacklogViewViaAPI(t, env, client, token, projID, "Bulk Backlog View", "table") - task1 := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - task2 := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + task1 := createTaskViaAPI(t, env, client, token, projID, "Bulk Backlog Task 1") + task2 := createTaskViaAPI(t, env, client, token, projID, "Bulk Backlog Task 2") bulkURL := fmt.Sprintf("%s/api/v1/projects/%s/views/%s/task-positions", env.base, projID, viewID) From 3721a7794efb6eec2ebcbebe09e052519056170c Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sun, 13 Sep 2026 06:34:41 +0000 Subject: [PATCH 4/4] feat: add user-specific view configuration support - Introduced personal view configurations for users, allowing them to customize their view settings without affecting shared configurations. - Added new API endpoints to clear and manage user-specific view settings. - Updated the database schema to include a new table for storing user view configurations. - Enhanced the view service to handle merging of user-specific settings with shared defaults. - Implemented necessary changes in the frontend localization files to support new view settings options in multiple languages. - Added tests to ensure the correct functionality of user-specific view configurations and their interactions with shared settings. --- .../interactions/interaction-layout.tsx | 29 ++- .../interactions/view-settings-panel.tsx | 128 +++++++++--- apps/web/src/i18n/locales/en/projects.json | 3 + apps/web/src/i18n/locales/es/projects.json | 3 + apps/web/src/i18n/locales/fr/projects.json | 3 + apps/web/src/i18n/locales/ja/projects.json | 3 + apps/web/src/i18n/locales/ko/projects.json | 3 + apps/web/src/i18n/locales/pt-BR/projects.json | 3 + apps/web/src/i18n/locales/ru/projects.json | 3 + apps/web/src/i18n/locales/vi/projects.json | 3 + apps/web/src/i18n/locales/zh-CN/projects.json | 3 + apps/web/src/lib/interaction-api.ts | 19 ++ services/api/internal/domain/sprint/entity.go | 16 ++ .../api/internal/domain/sprint/repository.go | 4 + .../api/internal/domain/sprint/service.go | 15 +- .../repository/postgres/view_repository.go | 12 ++ .../service/sprint/cached_service_test.go | 4 + .../service/sprint/cached_view_service.go | 6 + .../internal/service/sprint/view_service.go | 170 ++++++++++++++-- .../service/sprint/view_service_test.go | 186 ++++++++++++++++++ .../internal/transport/http/dto/view_dto.go | 66 ++++--- .../http/handler/sprint_handler_test.go | 4 + .../http/handler/task_handler_test.go | 4 + .../transport/http/handler/view_handler.go | 28 +++ .../internal/transport/http/router/router.go | 4 + ...s.sql => 000057_add_user_view_configs.sql} | 2 +- services/api/test/integration/view_test.go | 7 + 27 files changed, 668 insertions(+), 63 deletions(-) rename services/api/migrations/{000048_add_user_view_configs.sql => 000057_add_user_view_configs.sql} (97%) diff --git a/apps/web/src/components/projects/interactions/interaction-layout.tsx b/apps/web/src/components/projects/interactions/interaction-layout.tsx index 18a77d116..ba78cf1fa 100644 --- a/apps/web/src/components/projects/interactions/interaction-layout.tsx +++ b/apps/web/src/components/projects/interactions/interaction-layout.tsx @@ -50,6 +50,7 @@ import { allTasksQueryOptions, bulkMoveViewTaskPositions, type CustomFieldFilterQuery, + clearMyViewConfig, createSprint, createTask, createViewByContext, @@ -1653,6 +1654,24 @@ export function InteractionLayout({ }, }); + // Publishes the settings-panel draft as the shared view everyone sees. + // Two steps, not one: PATCHing the shared config alone would leave the + // publisher's own override in place — now redundant with (but no longer + // tracking future changes to) what they just made the team default — so + // it's cleared right after. + const saveForEveryoneMutation = useMutation({ + mutationFn: async (payload: { viewId: string; config: ViewConfig }) => { + await updateViewById(projectId, payload.viewId, { + config: payload.config, + }); + await clearMyViewConfig(projectId, payload.viewId); + }, + onSuccess: () => { + setPreviewConfig(undefined); + qc.invalidateQueries({ queryKey: viewsQueryKey }); + }, + }); + const deleteViewMutation = useMutation({ mutationFn: (viewId: string) => deleteViewById(projectId, viewId), onSuccess: (_, deletedId) => { @@ -1966,7 +1985,15 @@ export function InteractionLayout({ updateViewConfigMutation.mutateAsync({ viewId, config }) } onPreview={setPreviewConfig} - isPending={updateViewConfigMutation.isPending} + isPending={ + updateViewConfigMutation.isPending || + saveForEveryoneMutation.isPending + } + isPersonalized={activeView.is_personalized} + canSaveForEveryone={canManageViews} + onSaveForEveryone={(viewId, config) => + saveForEveryoneMutation.mutateAsync({ viewId, config }) + } /> )} diff --git a/apps/web/src/components/projects/interactions/view-settings-panel.tsx b/apps/web/src/components/projects/interactions/view-settings-panel.tsx index f67739b34..2c2baa670 100644 --- a/apps/web/src/components/projects/interactions/view-settings-panel.tsx +++ b/apps/web/src/components/projects/interactions/view-settings-panel.tsx @@ -1118,6 +1118,12 @@ interface ViewSettingsPanelProps { onSave: (viewId: string, config: ViewConfig) => Promise; onPreview: (config: ViewConfig) => void; isPending?: boolean; + /** True when `view.config` is this user's personal override. */ + isPersonalized?: boolean; + /** True when the caller may publish these settings to every project member (views.write). */ + canSaveForEveryone?: boolean; + /** Saves the current draft as the shared view everyone sees, clearing the caller's own override. */ + onSaveForEveryone: (viewId: string, config: ViewConfig) => Promise; } export function ViewSettingsPanel({ @@ -1128,6 +1134,9 @@ export function ViewSettingsPanel({ onSave, onPreview, isPending, + isPersonalized, + canSaveForEveryone, + onSaveForEveryone, }: ViewSettingsPanelProps) { const { t } = useTranslation("projects"); const { data: customFields = [] } = useQuery( @@ -1142,6 +1151,18 @@ export function ViewSettingsPanel({ const [draft, setDraft] = useState(() => view?.config ?? {}); const [fieldsOpen, setFieldsOpen] = useState(false); + // Only offer Reset when there's actually something to reset — the draft + // (including unsaved edits) differs from the shared/team config. Also + // gates "Save for everyone": publishing an unchanged shared config would + // be a no-op. + const differsFromShared = + JSON.stringify(draft) !== JSON.stringify(view?.shared_config ?? {}); + // Gates the personal-only save actions ("Save" / "Save only for me"): + // nothing to persist if the draft matches what's already effectively + // active for this user (their own override, or the shared value if they + // have none). + const differsFromEffective = + JSON.stringify(draft) !== JSON.stringify(view?.config ?? {}); // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally keyed on view?.id useEffect(() => { @@ -1218,13 +1239,25 @@ export function ViewSettingsPanel({ onOpenChange(false); }; + // Resets the draft to the team default, not to whatever was last saved — + // a personalized view's own saved override is not "default" from the + // user's perspective. Purely local: still requires Save (or Save for + // everyone) to persist, matching how a form's "reset" conventionally + // repopulates fields without submitting them. const handleReset = () => { - const saved = view?.config ?? {}; - setDraft(saved); - onPreview(saved); + const shared = view?.shared_config ?? {}; + setDraft(shared); + onPreview(shared); setFieldsOpen(false); }; + const handleSaveForEveryone = async () => { + if (!view) return; + await onSaveForEveryone(view.id, draft); + setFieldsOpen(false); + onOpenChange(false); + }; + const visibleFields: string[] = draft.fields && draft.fields.length > 0 ? draft.fields @@ -1324,9 +1357,16 @@ export function ViewSettingsPanel({ ) : ( <> -

- {t("layout.viewSettings.title")} -

+
+

+ {t("layout.viewSettings.title")} +

+ {isPersonalized && ( + + {t("layout.viewSettings.onlyVisibleToYou")} + + )} +
{hasSavedFilters && ( - + {differsFromShared && ( + + )} + {canSaveForEveryone ? ( +
+ {/* "Save for everyone" is primary here: for anyone who can + publish shared settings, that's the common case — a + personal-only save is the exception, tucked in the menu. */} + + + + + + + + + +
+ ) : ( + + )} diff --git a/apps/web/src/i18n/locales/en/projects.json b/apps/web/src/i18n/locales/en/projects.json index d6adcc776..3e4f63de5 100644 --- a/apps/web/src/i18n/locales/en/projects.json +++ b/apps/web/src/i18n/locales/en/projects.json @@ -1613,6 +1613,7 @@ "viewSettings": { "viewSettingsAriaLabel": "View settings", "title": "View settings", + "onlyVisibleToYou": "Only visible to you", "chooseFields": "Choose fields", "back": "← Back", "clearFilters": "Clear filters", @@ -1639,6 +1640,8 @@ "reset": "Reset", "saving": "Saving…", "save": "Save", + "saveOnlyForMe": "Save only for me", + "saveForEveryone": "Save for everyone", "sprintFilter": { "noSprints": "No sprints", "allSprints": "All sprints" diff --git a/apps/web/src/i18n/locales/es/projects.json b/apps/web/src/i18n/locales/es/projects.json index 3bd1e904c..e820c1e0d 100644 --- a/apps/web/src/i18n/locales/es/projects.json +++ b/apps/web/src/i18n/locales/es/projects.json @@ -1613,6 +1613,7 @@ "viewSettings": { "viewSettingsAriaLabel": "Configuración de vista", "title": "Configuración de vista", + "onlyVisibleToYou": "Solo visible para ti", "chooseFields": "Elegir campos", "back": "← Atrás", "clearFilters": "Borrar filtros", @@ -1639,6 +1640,8 @@ "reset": "Restablecer", "saving": "Guardando…", "save": "Guardar", + "saveOnlyForMe": "Guardar solo para mí", + "saveForEveryone": "Guardar para todos", "sprintFilter": { "noSprints": "No hay sprints", "allSprints": "Todos los sprints" diff --git a/apps/web/src/i18n/locales/fr/projects.json b/apps/web/src/i18n/locales/fr/projects.json index 5cec9348d..de468d59e 100644 --- a/apps/web/src/i18n/locales/fr/projects.json +++ b/apps/web/src/i18n/locales/fr/projects.json @@ -1613,6 +1613,7 @@ "viewSettings": { "viewSettingsAriaLabel": "Paramètres de la vue", "title": "Paramètres de la vue", + "onlyVisibleToYou": "Visible uniquement par vous", "chooseFields": "Choisir les champs", "back": "← Retour", "clearFilters": "Effacer les filtres", @@ -1639,6 +1640,8 @@ "reset": "Réinitialiser", "saving": "Enregistrement…", "save": "Enregistrer", + "saveOnlyForMe": "Enregistrer seulement pour moi", + "saveForEveryone": "Enregistrer pour tout le monde", "sprintFilter": { "noSprints": "Aucun sprint", "allSprints": "Tous les sprints" diff --git a/apps/web/src/i18n/locales/ja/projects.json b/apps/web/src/i18n/locales/ja/projects.json index a30fba830..7e1014385 100644 --- a/apps/web/src/i18n/locales/ja/projects.json +++ b/apps/web/src/i18n/locales/ja/projects.json @@ -1613,6 +1613,7 @@ "viewSettings": { "viewSettingsAriaLabel": "ビュー設定", "title": "ビュー設定", + "onlyVisibleToYou": "自分にのみ表示", "chooseFields": "フィールドを選択", "back": "← 戻る", "clearFilters": "フィルターをクリア", @@ -1639,6 +1640,8 @@ "reset": "リセット", "saving": "保存中…", "save": "保存", + "saveOnlyForMe": "自分だけに保存", + "saveForEveryone": "全員に保存", "sprintFilter": { "noSprints": "スプリントはありません", "allSprints": "すべてのスプリント" diff --git a/apps/web/src/i18n/locales/ko/projects.json b/apps/web/src/i18n/locales/ko/projects.json index 1988697db..dafc40717 100644 --- a/apps/web/src/i18n/locales/ko/projects.json +++ b/apps/web/src/i18n/locales/ko/projects.json @@ -1613,6 +1613,7 @@ "viewSettings": { "viewSettingsAriaLabel": "뷰 설정", "title": "뷰 설정", + "onlyVisibleToYou": "나에게만 표시됨", "chooseFields": "필드 선택", "back": "← 뒤로", "clearFilters": "필터 지우기", @@ -1639,6 +1640,8 @@ "reset": "초기화", "saving": "저장 중…", "save": "저장", + "saveOnlyForMe": "나만을 위해 저장", + "saveForEveryone": "모두를 위해 저장", "sprintFilter": { "noSprints": "스프린트 없음", "allSprints": "모든 스프린트" diff --git a/apps/web/src/i18n/locales/pt-BR/projects.json b/apps/web/src/i18n/locales/pt-BR/projects.json index 06960c1b9..17f06d68b 100644 --- a/apps/web/src/i18n/locales/pt-BR/projects.json +++ b/apps/web/src/i18n/locales/pt-BR/projects.json @@ -1613,6 +1613,7 @@ "viewSettings": { "viewSettingsAriaLabel": "Configurações da visão", "title": "Configurações da visão", + "onlyVisibleToYou": "Visível somente para você", "chooseFields": "Escolher campos", "back": "← Voltar", "clearFilters": "Limpar filtros", @@ -1639,6 +1640,8 @@ "reset": "Redefinir", "saving": "Salvando…", "save": "Salvar", + "saveOnlyForMe": "Salvar somente para mim", + "saveForEveryone": "Salvar para todos", "sprintFilter": { "noSprints": "Nenhum sprint", "allSprints": "Todos os sprints" diff --git a/apps/web/src/i18n/locales/ru/projects.json b/apps/web/src/i18n/locales/ru/projects.json index 14b624097..b254f0e5b 100644 --- a/apps/web/src/i18n/locales/ru/projects.json +++ b/apps/web/src/i18n/locales/ru/projects.json @@ -1631,6 +1631,7 @@ "viewSettings": { "viewSettingsAriaLabel": "Настройки представления", "title": "Настройки представления", + "onlyVisibleToYou": "Видно только вам", "chooseFields": "Выбрать поля", "back": "← Назад", "clearFilters": "Очистить фильтры", @@ -1657,6 +1658,8 @@ "reset": "Сбросить", "saving": "Сохранение…", "save": "Сохранить", + "saveOnlyForMe": "Сохранить только для меня", + "saveForEveryone": "Сохранить для всех", "sprintFilter": { "noSprints": "Нет спринтов", "allSprints": "Все спринты" diff --git a/apps/web/src/i18n/locales/vi/projects.json b/apps/web/src/i18n/locales/vi/projects.json index 7577da7c9..4fa9a45ee 100644 --- a/apps/web/src/i18n/locales/vi/projects.json +++ b/apps/web/src/i18n/locales/vi/projects.json @@ -1613,6 +1613,7 @@ "viewSettings": { "viewSettingsAriaLabel": "Cài đặt chế độ xem", "title": "Cài đặt chế độ xem", + "onlyVisibleToYou": "Chỉ bạn nhìn thấy", "chooseFields": "Chọn trường", "back": "← Quay lại", "clearFilters": "Xóa bộ lọc", @@ -1639,6 +1640,8 @@ "reset": "Đặt lại", "saving": "Đang lưu…", "save": "Lưu", + "saveOnlyForMe": "Chỉ lưu cho tôi", + "saveForEveryone": "Lưu cho mọi người", "sprintFilter": { "noSprints": "Không có sprint", "allSprints": "Tất cả sprint" diff --git a/apps/web/src/i18n/locales/zh-CN/projects.json b/apps/web/src/i18n/locales/zh-CN/projects.json index 697383142..3829dab06 100644 --- a/apps/web/src/i18n/locales/zh-CN/projects.json +++ b/apps/web/src/i18n/locales/zh-CN/projects.json @@ -1613,6 +1613,7 @@ "viewSettings": { "viewSettingsAriaLabel": "视图设置", "title": "视图设置", + "onlyVisibleToYou": "仅自己可见", "chooseFields": "选择字段", "back": "← 返回", "clearFilters": "清除筛选", @@ -1639,6 +1640,8 @@ "reset": "重置", "saving": "正在保存…", "save": "保存", + "saveOnlyForMe": "仅为自己保存", + "saveForEveryone": "保存给所有人", "sprintFilter": { "noSprints": "暂无冲刺", "allSprints": "所有冲刺" diff --git a/apps/web/src/lib/interaction-api.ts b/apps/web/src/lib/interaction-api.ts index 0bd77eefe..7630bbd03 100644 --- a/apps/web/src/lib/interaction-api.ts +++ b/apps/web/src/lib/interaction-api.ts @@ -253,6 +253,10 @@ export interface InteractionView { layout: ViewLayout; config?: ViewConfig; position: number; + /** True when `config` is this user's personal override, not what other project members see. */ + is_personalized: boolean; + /** The project-shared default (equal to `config` when `is_personalized` is false). */ + shared_config: ViewConfig; } // ── View shape helpers ───────────────────────────────────────────────────────── @@ -353,6 +357,21 @@ export async function updateMyViewConfig( return mapView(data.data); } +/** + * Clear the current user's PERSONAL view config, reverting them to the + * shared default (see {@link updateMyViewConfig}). Never touches the shared + * view or other members. + */ +export async function clearMyViewConfig( + projectId: string, + viewId: string, +): Promise { + const { data } = await apiClient.instance.delete< + SuccessEnvelope> + >(`/projects/${projectId}/views/${viewId}/config`); + return mapView(data.data); +} + export async function deleteViewById( projectId: string, viewId: string, diff --git a/services/api/internal/domain/sprint/entity.go b/services/api/internal/domain/sprint/entity.go index eea62528a..d36877780 100644 --- a/services/api/internal/domain/sprint/entity.go +++ b/services/api/internal/domain/sprint/entity.go @@ -220,6 +220,22 @@ type SprintView struct { ViewContext ViewContext CreatedAt time.Time UpdatedAt time.Time + + // HasPersonalConfig is set in-memory by ViewService.OverlayUserConfigs to + // indicate whether Config reflects the caller's personal override rather + // than the shared default. It has no database column — repository code + // must never populate it — and is false on any SprintView obtained + // without going through OverlayUserConfigs. + HasPersonalConfig bool + + // SharedConfig is the project-shared default, set alongside + // HasPersonalConfig whenever it is true (i.e. whenever Config has been + // overwritten with a merged/override value) so callers can still see + // what a personalized view falls back to — e.g. a "reset to team + // default" UI action. When HasPersonalConfig is false, Config already + // IS the shared value, so SharedConfig is left unset; read it via + // Config in that case instead. + SharedConfig ViewConfig } // ViewTaskPosition records the manual ordering of a task within a specific diff --git a/services/api/internal/domain/sprint/repository.go b/services/api/internal/domain/sprint/repository.go index 540bc6a77..e834b47fc 100644 --- a/services/api/internal/domain/sprint/repository.go +++ b/services/api/internal/domain/sprint/repository.go @@ -62,6 +62,10 @@ type ViewRepository interface { // UpsertUserViewConfig stores (or replaces) a user's personal config for a // single view. UpsertUserViewConfig(ctx context.Context, viewID, userID uuid.UUID, cfg ViewConfig) error + + // DeleteUserViewConfig removes a user's personal override for a view, if + // one exists. Deleting a nonexistent override is not an error. + DeleteUserViewConfig(ctx context.Context, viewID, userID uuid.UUID) error } // ViewReorderItem carries the new position for a single view. diff --git a/services/api/internal/domain/sprint/service.go b/services/api/internal/domain/sprint/service.go index 7da6a5f1f..d1e6d4591 100644 --- a/services/api/internal/domain/sprint/service.go +++ b/services/api/internal/domain/sprint/service.go @@ -102,11 +102,18 @@ type ViewService interface { // it never touches the shared view row and emits no project-wide event. SetUserViewConfig(ctx context.Context, projectID, viewID, userID uuid.UUID, cfg ViewConfig) (*SprintView, error) - // OverlayUserConfigs replaces each view's Config with the user's personal - // override where one exists, leaving the shared default otherwise. It is - // safe to call with views obtained from a cache: only the returned copies - // are mutated. A nil user (unauthenticated) is a no-op. + // OverlayUserConfigs merges each view's Config with the user's personal + // override where one exists (field by field — a field the user never + // personalized keeps tracking the shared default), leaving the shared + // default entirely otherwise. Also sets HasPersonalConfig on each view. + // It is safe to call with views obtained from a cache: only the returned + // copies are mutated. A nil user (unauthenticated) is a no-op. OverlayUserConfigs(ctx context.Context, userID uuid.UUID, views []*SprintView) error + + // ClearUserViewConfig removes the current user's personal override for a + // view, verifying it belongs to projectID, and returns the view now + // carrying the shared default config (HasPersonalConfig is false). + ClearUserViewConfig(ctx context.Context, projectID, viewID, userID uuid.UUID) (*SprintView, error) } // CreateViewInput carries fields required to create a sprint view. diff --git a/services/api/internal/repository/postgres/view_repository.go b/services/api/internal/repository/postgres/view_repository.go index ba02df354..411613c1d 100644 --- a/services/api/internal/repository/postgres/view_repository.go +++ b/services/api/internal/repository/postgres/view_repository.go @@ -308,6 +308,18 @@ func (r *ViewRepository) UpsertUserViewConfig(ctx context.Context, viewID, userI return nil } +// DeleteUserViewConfig removes a user's personal config for a view, if one exists. +func (r *ViewRepository) DeleteUserViewConfig(ctx context.Context, viewID, userID uuid.UUID) error { + _, err := r.db.ExecContext(ctx, + `DELETE FROM user_view_configs WHERE view_id = $1 AND user_id = $2`, + viewID.String(), userID.String(), + ) + if err != nil { + return fmt.Errorf("view repo: delete user view config: %w", err) + } + return nil +} + // --- Entity converters ------------------------------------------------------ func toViewEntity(r *sprintViewRecord) (*sprintdom.SprintView, error) { diff --git a/services/api/internal/service/sprint/cached_service_test.go b/services/api/internal/service/sprint/cached_service_test.go index 279e3b0eb..58ae813c3 100644 --- a/services/api/internal/service/sprint/cached_service_test.go +++ b/services/api/internal/service/sprint/cached_service_test.go @@ -430,6 +430,10 @@ func (s *stubViewSvc) SetUserViewConfig(_ context.Context, _, _, _ uuid.UUID, _ return nil, nil } +func (s *stubViewSvc) ClearUserViewConfig(_ context.Context, _, _, _ uuid.UUID) (*sprintdom.SprintView, error) { + return nil, nil +} + func (s *stubViewSvc) OverlayUserConfigs(_ context.Context, _ uuid.UUID, _ []*sprintdom.SprintView) error { return nil } diff --git a/services/api/internal/service/sprint/cached_view_service.go b/services/api/internal/service/sprint/cached_view_service.go index 9673b05c5..f2a020c3d 100644 --- a/services/api/internal/service/sprint/cached_view_service.go +++ b/services/api/internal/service/sprint/cached_view_service.go @@ -205,6 +205,12 @@ func (c *CachedViewService) SetUserViewConfig(ctx context.Context, projectID, vi return c.svc.SetUserViewConfig(ctx, projectID, viewID, userID, cfg) } +// ClearUserViewConfig delegates directly to the underlying service. Personal +// configs are never cached, so no invalidation is needed. +func (c *CachedViewService) ClearUserViewConfig(ctx context.Context, projectID, viewID, userID uuid.UUID) (*sprintdom.SprintView, error) { + return c.svc.ClearUserViewConfig(ctx, projectID, viewID, userID) +} + // OverlayUserConfigs delegates directly to the underlying service. It must run // after the cache is read so the shared cached entries are never polluted with // one user's personal config. diff --git a/services/api/internal/service/sprint/view_service.go b/services/api/internal/service/sprint/view_service.go index 6da4fc693..3df8f67da 100644 --- a/services/api/internal/service/sprint/view_service.go +++ b/services/api/internal/service/sprint/view_service.go @@ -3,6 +3,7 @@ package sprintsvc import ( "context" + "reflect" "strings" "time" @@ -354,10 +355,27 @@ func (s *ViewService) ReorderProjectViews(ctx context.Context, projectID uuid.UU } // SetUserViewConfig stores the current user's personal config for a view, -// verifying it belongs to projectID, and returns the view carrying that config. -// The write is private to the user: the shared sprint_views row is untouched -// and no project-wide real-time event is published, so other members are -// unaffected. +// verifying it belongs to projectID, and returns the view carrying the +// effective (merged) config the caller just set. The write is private to the +// user: the shared sprint_views row is untouched and no project-wide +// real-time event is published, so other members are unaffected. +// +// cfg is diffed against the *current* shared row and only the fields that +// actually differ are persisted as the override (see diffViewConfig) — a +// field the user leaves matching the shared value keeps tracking that shared +// value if it changes later, instead of freezing at today's snapshot. When +// every field matches shared (the diff is empty), any existing override row +// is removed rather than upserting a no-op, so HasPersonalConfig stays +// accurate and user_view_configs doesn't accumulate empty rows. +// +// Known race (accepted, not fixed): cfg reflects whatever the caller's UI +// last fetched the shared config as, which can be stale if an admin changes +// the shared default while the caller's settings panel is still open. A +// field the user never touched could then be re-diffed against a shared +// value that has since moved, and get spuriously captured in the override. +// This is narrow (requires a concurrent shared-config edit mid-edit) and +// self-healing (ClearUserViewConfig/"use team default" recovers it in one +// click, which didn't exist before this override model). func (s *ViewService) SetUserViewConfig(ctx context.Context, projectID, viewID, userID uuid.UUID, cfg sprintdom.ViewConfig) (*sprintdom.SprintView, error) { v, err := s.repo.FindViewByID(ctx, viewID) if err != nil { @@ -366,20 +384,50 @@ func (s *ViewService) SetUserViewConfig(ctx context.Context, projectID, viewID, if v.ProjectID != projectID { return nil, sprintdom.ErrViewNotFound } - // A plugin view still needs its plugin binding in the personal config. + // A plugin view still needs its plugin binding in the effective config. if !hasPluginConfig(v.ViewType, &cfg) { return nil, sprintdom.ErrViewPluginConfigRequired } - if err := s.repo.UpsertUserViewConfig(ctx, viewID, userID, cfg); err != nil { - return nil, err + sparse := diffViewConfig(v.Config, cfg) + sharedConfig := v.Config + if isZeroViewConfig(sparse) { + if err := s.repo.DeleteUserViewConfig(ctx, viewID, userID); err != nil { + return nil, err + } + v.HasPersonalConfig = false + } else { + if err := s.repo.UpsertUserViewConfig(ctx, viewID, userID, sparse); err != nil { + return nil, err + } + v.HasPersonalConfig = true + v.SharedConfig = sharedConfig } v.Config = cfg return v, nil } -// OverlayUserConfigs replaces each view's Config with the user's personal -// override where one exists, leaving the shared default otherwise. A nil user -// or empty view list is a no-op. The passed views are mutated in place; callers +// ClearUserViewConfig removes the current user's personal override for a +// view, verifying it belongs to projectID, and returns the view now carrying +// the shared default (HasPersonalConfig is false). Deleting a nonexistent +// override is a harmless no-op, matching UpsertUserViewConfig's idempotence. +func (s *ViewService) ClearUserViewConfig(ctx context.Context, projectID, viewID, userID uuid.UUID) (*sprintdom.SprintView, error) { + v, err := s.repo.FindViewByID(ctx, viewID) + if err != nil { + return nil, err + } + if v.ProjectID != projectID { + return nil, sprintdom.ErrViewNotFound + } + if err := s.repo.DeleteUserViewConfig(ctx, viewID, userID); err != nil { + return nil, err + } + return v, nil +} + +// OverlayUserConfigs merges each view's Config with the user's personal +// override where one exists (see mergeViewConfig) and sets HasPersonalConfig +// accordingly, leaving views without an override untouched. A nil user or +// empty view list is a no-op. The passed views are mutated in place; callers // pass per-request copies (cache hits deserialize fresh objects), so the shared // cache is never affected. func (s *ViewService) OverlayUserConfigs(ctx context.Context, userID uuid.UUID, views []*sprintdom.SprintView) error { @@ -403,13 +451,111 @@ func (s *ViewService) OverlayUserConfigs(ctx context.Context, userID uuid.UUID, if v == nil { continue } - if cfg, ok := overrides[v.ID]; ok { - v.Config = cfg + cfg, ok := overrides[v.ID] + v.HasPersonalConfig = ok + if ok { + v.SharedConfig = v.Config + v.Config = mergeViewConfig(v.SharedConfig, cfg) } } return nil } +// mergeViewConfig returns the effective config for a view: each field of +// override wins when the user has personally set it; shared's value is used +// otherwise. PluginID/PluginComponent always come from shared — plugin +// binding is structural, never a personal preference (see hasPluginConfig). +// +// Known limitation: Fields and CollapsedColumns, like the plain string/int +// fields below, cannot distinguish "the user explicitly chose zero items" +// from "never touched" — both collapse to Go's zero value. This predates +// this merge logic (the single-layer "empty means default" convention +// already had the same ambiguity for a single config) and isn't made worse +// by it; fully closing it would require changing every field to a +// pointer/presence-tracked type, a much larger wire-format change not +// justified for the win it buys. +func mergeViewConfig(shared, override sprintdom.ViewConfig) sprintdom.ViewConfig { + out := shared + if len(override.Fields) > 0 { + out.Fields = override.Fields + } + if override.ColumnBy != "" { + out.ColumnBy = override.ColumnBy + } + if override.Swimlanes != "" { + out.Swimlanes = override.Swimlanes + } + if override.SortBy != "" { + out.SortBy = override.SortBy + } + if override.FieldSum != "" { + out.FieldSum = override.FieldSum + } + if override.SliceBy != "" { + out.SliceBy = override.SliceBy + } + if override.Filters != nil { + out.Filters = override.Filters + } + if len(override.CollapsedColumns) > 0 { + out.CollapsedColumns = override.CollapsedColumns + } + if override.PageSize != 0 { + out.PageSize = override.PageSize + } + if override.InitialPageSize != 0 { + out.InitialPageSize = override.InitialPageSize + } + // out.PluginID / out.PluginComponent intentionally left at shared's value. + return out +} + +// diffViewConfig returns the sparse subset of desired that differs from +// shared, suitable for persisting as a personal override: fields identical +// to shared are left at Go zero value so a later mergeViewConfig falls back +// to whatever shared holds at read time — even if shared changes afterward. +// PluginID/PluginComponent are never included (see mergeViewConfig). +func diffViewConfig(shared, desired sprintdom.ViewConfig) sprintdom.ViewConfig { + var out sprintdom.ViewConfig + if !reflect.DeepEqual(desired.Fields, shared.Fields) { + out.Fields = desired.Fields + } + if desired.ColumnBy != shared.ColumnBy { + out.ColumnBy = desired.ColumnBy + } + if desired.Swimlanes != shared.Swimlanes { + out.Swimlanes = desired.Swimlanes + } + if desired.SortBy != shared.SortBy { + out.SortBy = desired.SortBy + } + if desired.FieldSum != shared.FieldSum { + out.FieldSum = desired.FieldSum + } + if desired.SliceBy != shared.SliceBy { + out.SliceBy = desired.SliceBy + } + if !reflect.DeepEqual(desired.Filters, shared.Filters) { + out.Filters = desired.Filters + } + if !reflect.DeepEqual(desired.CollapsedColumns, shared.CollapsedColumns) { + out.CollapsedColumns = desired.CollapsedColumns + } + if desired.PageSize != shared.PageSize { + out.PageSize = desired.PageSize + } + if desired.InitialPageSize != shared.InitialPageSize { + out.InitialPageSize = desired.InitialPageSize + } + return out +} + +// isZeroViewConfig reports whether cfg has no fields set — i.e. an override +// that would make no difference once merged with any shared config. +func isZeroViewConfig(cfg sprintdom.ViewConfig) bool { + return reflect.DeepEqual(cfg, sprintdom.ViewConfig{}) +} + // validateAndReorder checks that viewIDs exactly matches the IDs of existing // views (same count, no unknowns) then persists the new positions. func (s *ViewService) validateAndReorder(ctx context.Context, existing []*sprintdom.SprintView, viewIDs []uuid.UUID) error { diff --git a/services/api/internal/service/sprint/view_service_test.go b/services/api/internal/service/sprint/view_service_test.go index 9ea0443d6..2b8ff14b4 100644 --- a/services/api/internal/service/sprint/view_service_test.go +++ b/services/api/internal/service/sprint/view_service_test.go @@ -116,6 +116,13 @@ func (r *fakeViewRepo) UpsertUserViewConfig(_ context.Context, viewID, userID uu return nil } +func (r *fakeViewRepo) DeleteUserViewConfig(_ context.Context, viewID, userID uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.userConfigs, userCfgKey(viewID, userID)) + return nil +} + func posKey(viewID, taskID uuid.UUID) string { return viewID.String() + ":" + taskID.String() } @@ -1276,6 +1283,185 @@ func TestViewService_OverlayUserConfigs_NilUserIsNoop(t *testing.T) { } } +// --------------------------------------------------------------------------- +// Field-level merge: personalizing one field must not freeze every other +// field away from later shared-default changes (the bug this redesign fixes). +// --------------------------------------------------------------------------- + +func TestViewService_OverlayUserConfigs_UnsetFieldsTrackLaterSharedChanges(t *testing.T) { + ctx := context.Background() + repo := newFakeViewRepo() + svc := sprintsvc.NewViewService(repo, permissiveSprintRepo{}, permissiveTaskRepo{}, nil) + + projectID := uuid.New() + view := seedProjectView(t, svc, projectID, sprintdom.ViewConfig{SortBy: "created", PageSize: 25}) + userA := uuid.New() + + // User personalizes only SortBy; PageSize in their save matches shared, + // so it must not be captured in the stored override. + if _, err := svc.SetUserViewConfig(ctx, projectID, view.ID, userA, sprintdom.ViewConfig{SortBy: "importance", PageSize: 25}); err != nil { + t.Fatalf("SetUserViewConfig: %v", err) + } + + // The shared default's PageSize changes later (e.g. an admin edit, or + // "set as team default" from another member). + shared, err := repo.FindViewByID(ctx, view.ID) + if err != nil { + t.Fatalf("FindViewByID: %v", err) + } + shared.Config.PageSize = 50 + if err := repo.UpdateView(ctx, shared); err != nil { + t.Fatalf("UpdateView: %v", err) + } + + // Simulate the shared view coming back from a fresh list/cache read. + views := []*sprintdom.SprintView{{ID: view.ID, ProjectID: projectID, Config: shared.Config}} + if err := svc.OverlayUserConfigs(ctx, userA, views); err != nil { + t.Fatalf("OverlayUserConfigs: %v", err) + } + if views[0].Config.SortBy != "importance" { + t.Errorf("personalized field lost: got %q, want %q", views[0].Config.SortBy, "importance") + } + if views[0].Config.PageSize != 50 { + t.Errorf("un-personalized field frozen: got %d, want 50 (should track the new shared default)", views[0].Config.PageSize) + } +} + +func TestViewService_SetUserViewConfig_RevertingToSharedValuesClearsOverride(t *testing.T) { + ctx := context.Background() + repo := newFakeViewRepo() + svc := sprintsvc.NewViewService(repo, permissiveSprintRepo{}, permissiveTaskRepo{}, nil) + + projectID := uuid.New() + view := seedProjectView(t, svc, projectID, sprintdom.ViewConfig{SortBy: "created"}) + userA := uuid.New() + + if _, err := svc.SetUserViewConfig(ctx, projectID, view.ID, userA, sprintdom.ViewConfig{SortBy: "importance"}); err != nil { + t.Fatalf("SetUserViewConfig (personalize): %v", err) + } + got, err := svc.SetUserViewConfig(ctx, projectID, view.ID, userA, sprintdom.ViewConfig{SortBy: "created"}) + if err != nil { + t.Fatalf("SetUserViewConfig (revert): %v", err) + } + if got.HasPersonalConfig { + t.Errorf("expected HasPersonalConfig=false after reverting to shared values") + } + + overrides, err := repo.GetUserViewConfigs(ctx, userA, []uuid.UUID{view.ID}) + if err != nil { + t.Fatalf("GetUserViewConfigs: %v", err) + } + if _, ok := overrides[view.ID]; ok { + t.Errorf("expected override row to be removed once every field matches shared") + } +} + +func TestViewService_ClearUserViewConfig_RemovesOverride(t *testing.T) { + ctx := context.Background() + repo := newFakeViewRepo() + svc := sprintsvc.NewViewService(repo, permissiveSprintRepo{}, permissiveTaskRepo{}, nil) + + projectID := uuid.New() + view := seedProjectView(t, svc, projectID, sprintdom.ViewConfig{SortBy: "created"}) + userA := uuid.New() + + if _, err := svc.SetUserViewConfig(ctx, projectID, view.ID, userA, sprintdom.ViewConfig{SortBy: "importance"}); err != nil { + t.Fatalf("SetUserViewConfig: %v", err) + } + got, err := svc.ClearUserViewConfig(ctx, projectID, view.ID, userA) + if err != nil { + t.Fatalf("ClearUserViewConfig: %v", err) + } + if got.Config.SortBy != "created" { + t.Errorf("expected shared default after clearing, got %q", got.Config.SortBy) + } + + overrides, err := repo.GetUserViewConfigs(ctx, userA, []uuid.UUID{view.ID}) + if err != nil { + t.Fatalf("GetUserViewConfigs: %v", err) + } + if _, ok := overrides[view.ID]; ok { + t.Errorf("expected override row to be gone after ClearUserViewConfig") + } +} + +func TestViewService_ClearUserViewConfig_WrongProjectReturnsNotFound(t *testing.T) { + ctx := context.Background() + svc := sprintsvc.NewViewService(newFakeViewRepo(), permissiveSprintRepo{}, permissiveTaskRepo{}, nil) + + view := seedProjectView(t, svc, uuid.New(), sprintdom.ViewConfig{}) + _, err := svc.ClearUserViewConfig(ctx, uuid.New() /* wrong project */, view.ID, uuid.New()) + if err != sprintdom.ErrViewNotFound { + t.Errorf("expected ErrViewNotFound, got %v", err) + } +} + +func TestViewService_OverlayUserConfigs_SetsHasPersonalConfigFlag(t *testing.T) { + ctx := context.Background() + repo := newFakeViewRepo() + svc := sprintsvc.NewViewService(repo, permissiveSprintRepo{}, permissiveTaskRepo{}, nil) + + projectID := uuid.New() + v1 := seedProjectView(t, svc, projectID, sprintdom.ViewConfig{SortBy: "created"}) + v2 := seedProjectView(t, svc, projectID, sprintdom.ViewConfig{SortBy: "created"}) + userA := uuid.New() + + if _, err := svc.SetUserViewConfig(ctx, projectID, v1.ID, userA, sprintdom.ViewConfig{SortBy: "importance"}); err != nil { + t.Fatalf("SetUserViewConfig: %v", err) + } + + views := []*sprintdom.SprintView{ + {ID: v1.ID, ProjectID: projectID, Config: sprintdom.ViewConfig{SortBy: "created"}}, + {ID: v2.ID, ProjectID: projectID, Config: sprintdom.ViewConfig{SortBy: "created"}}, + } + if err := svc.OverlayUserConfigs(ctx, userA, views); err != nil { + t.Fatalf("OverlayUserConfigs: %v", err) + } + if !views[0].HasPersonalConfig { + t.Errorf("v1: expected HasPersonalConfig=true") + } + if views[1].HasPersonalConfig { + t.Errorf("v2: expected HasPersonalConfig=false") + } +} + +// TestViewService_SharedConfig_SurvivesPersonalization verifies that a +// personalized view still exposes the original shared value (not the merged +// effective one) via SharedConfig, both right after SetUserViewConfig and on +// a later OverlayUserConfigs read — this is what the frontend's "reset to +// team default" reads. +func TestViewService_SharedConfig_SurvivesPersonalization(t *testing.T) { + ctx := context.Background() + repo := newFakeViewRepo() + svc := sprintsvc.NewViewService(repo, permissiveSprintRepo{}, permissiveTaskRepo{}, nil) + + projectID := uuid.New() + view := seedProjectView(t, svc, projectID, sprintdom.ViewConfig{SortBy: "created", PageSize: 25}) + userA := uuid.New() + + got, err := svc.SetUserViewConfig(ctx, projectID, view.ID, userA, sprintdom.ViewConfig{SortBy: "importance", PageSize: 25}) + if err != nil { + t.Fatalf("SetUserViewConfig: %v", err) + } + if got.Config.SortBy != "importance" { + t.Errorf("effective SortBy = %q, want %q", got.Config.SortBy, "importance") + } + if got.SharedConfig.SortBy != "created" || got.SharedConfig.PageSize != 25 { + t.Errorf("SharedConfig after SetUserViewConfig = %+v, want shared row {created 25}", got.SharedConfig) + } + + views := []*sprintdom.SprintView{{ID: view.ID, ProjectID: projectID, Config: sprintdom.ViewConfig{SortBy: "created", PageSize: 25}}} + if err := svc.OverlayUserConfigs(ctx, userA, views); err != nil { + t.Fatalf("OverlayUserConfigs: %v", err) + } + if views[0].Config.SortBy != "importance" { + t.Errorf("effective SortBy after overlay = %q, want %q", views[0].Config.SortBy, "importance") + } + if views[0].SharedConfig.SortBy != "created" { + t.Errorf("SharedConfig after overlay = %+v, want SortBy=created", views[0].SharedConfig) + } +} + // --------------------------------------------------------------------------- // Cross-project isolation tests (same bug class as GHSA-xwmv-9c7h-g947) // diff --git a/services/api/internal/transport/http/dto/view_dto.go b/services/api/internal/transport/http/dto/view_dto.go index c7bace061..56e01a7e5 100644 --- a/services/api/internal/transport/http/dto/view_dto.go +++ b/services/api/internal/transport/http/dto/view_dto.go @@ -76,33 +76,55 @@ type ViewResponse struct { Position float64 `json:"position"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + // IsPersonalized is true when Config reflects the caller's own personal + // override rather than the shared default everyone else sees. No + // omitempty: the frontend needs an explicit false, not an absent key. + IsPersonalized bool `json:"is_personalized"` + // SharedConfig is the project-shared default, always present (equal to + // Config when IsPersonalized is false) so the client can offer "reset to + // team default" without a round trip. + SharedConfig ViewConfigDTO `json:"shared_config"` +} + +// viewConfigToDTO maps a domain ViewConfig to its wire representation. +func viewConfigToDTO(cfg sprintdom.ViewConfig) ViewConfigDTO { + return ViewConfigDTO{ + Fields: cfg.Fields, + ColumnBy: cfg.ColumnBy, + Swimlanes: cfg.Swimlanes, + SortBy: cfg.SortBy, + FieldSum: cfg.FieldSum, + SliceBy: cfg.SliceBy, + Filters: cfg.Filters, + CollapsedColumns: cfg.CollapsedColumns, + PageSize: cfg.PageSize, + InitialPageSize: cfg.InitialPageSize, + PluginManifestID: cfg.PluginID, + PluginComponent: cfg.PluginComponent, + } } // ViewFromEntity maps a domain SprintView to a ViewResponse DTO. func ViewFromEntity(v *sprintdom.SprintView) ViewResponse { + // SharedConfig only carries a meaningful value when HasPersonalConfig is + // true (see its doc comment on sprintdom.SprintView) — otherwise Config + // already IS the shared value. + sharedConfig := v.Config + if v.HasPersonalConfig { + sharedConfig = v.SharedConfig + } return ViewResponse{ - ID: v.ID, - SprintID: v.SprintID, - ProjectID: v.ProjectID, - Name: v.Name, - ViewType: v.ViewType, - Config: ViewConfigDTO{ - Fields: v.Config.Fields, - ColumnBy: v.Config.ColumnBy, - Swimlanes: v.Config.Swimlanes, - SortBy: v.Config.SortBy, - FieldSum: v.Config.FieldSum, - SliceBy: v.Config.SliceBy, - Filters: v.Config.Filters, - CollapsedColumns: v.Config.CollapsedColumns, - PageSize: v.Config.PageSize, - InitialPageSize: v.Config.InitialPageSize, - PluginManifestID: v.Config.PluginID, - PluginComponent: v.Config.PluginComponent, - }, - Position: v.Position, - CreatedAt: v.CreatedAt, - UpdatedAt: v.UpdatedAt, + ID: v.ID, + SprintID: v.SprintID, + ProjectID: v.ProjectID, + Name: v.Name, + ViewType: v.ViewType, + IsPersonalized: v.HasPersonalConfig, + Config: viewConfigToDTO(v.Config), + SharedConfig: viewConfigToDTO(sharedConfig), + Position: v.Position, + CreatedAt: v.CreatedAt, + UpdatedAt: v.UpdatedAt, } } diff --git a/services/api/internal/transport/http/handler/sprint_handler_test.go b/services/api/internal/transport/http/handler/sprint_handler_test.go index 90390a105..3d35d4cad 100644 --- a/services/api/internal/transport/http/handler/sprint_handler_test.go +++ b/services/api/internal/transport/http/handler/sprint_handler_test.go @@ -127,6 +127,10 @@ func (f *fakeViewSvcH) SetUserViewConfig(_ context.Context, _, _, _ uuid.UUID, _ return nil, nil } +func (f *fakeViewSvcH) ClearUserViewConfig(_ context.Context, _, _, _ uuid.UUID) (*sprintdom.SprintView, error) { + return nil, nil +} + func (f *fakeViewSvcH) OverlayUserConfigs(_ context.Context, _ uuid.UUID, _ []*sprintdom.SprintView) error { return nil } diff --git a/services/api/internal/transport/http/handler/task_handler_test.go b/services/api/internal/transport/http/handler/task_handler_test.go index 52ca40ec1..3a65a7c6b 100644 --- a/services/api/internal/transport/http/handler/task_handler_test.go +++ b/services/api/internal/transport/http/handler/task_handler_test.go @@ -461,6 +461,10 @@ func (f *fakeViewSvcTask) SetUserViewConfig(_ context.Context, _, _, _ uuid.UUID return nil, nil } +func (f *fakeViewSvcTask) ClearUserViewConfig(_ context.Context, _, _, _ uuid.UUID) (*sprintdom.SprintView, error) { + return nil, nil +} + func (f *fakeViewSvcTask) OverlayUserConfigs(_ context.Context, _ uuid.UUID, _ []*sprintdom.SprintView) error { return nil } diff --git a/services/api/internal/transport/http/handler/view_handler.go b/services/api/internal/transport/http/handler/view_handler.go index 524162a49..dd580f36d 100644 --- a/services/api/internal/transport/http/handler/view_handler.go +++ b/services/api/internal/transport/http/handler/view_handler.go @@ -230,6 +230,34 @@ func (h *ViewHandler) UpdateMyViewConfig(w http.ResponseWriter, r *http.Request) presenter.OK(w, r, dto.ViewFromEntity(v)) } +// ClearMyViewConfig handles DELETE /projects/:projectId/views/:viewId/config. +// It removes the authenticated user's personal view config, reverting them +// to the shared default. Never touches the shared view or other users. +func (h *ViewHandler) ClearMyViewConfig(w http.ResponseWriter, r *http.Request) { + projectID, err := parseProjectID(r) + if err != nil { + presenter.Error(w, r, err) + return + } + viewID, err := parseViewID(r) + if err != nil { + presenter.Error(w, r, err) + return + } + actorID, ok := middleware.ActorIDFromContext(r.Context()) + if !ok || actorID == uuid.Nil { + presenter.Error(w, r, apierr.New(apierr.CodeUnauthenticated, "authentication required")) + return + } + + v, err := h.svc.ClearUserViewConfig(r.Context(), projectID, viewID, actorID) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.OK(w, r, dto.ViewFromEntity(v)) +} + // DeleteView handles DELETE /sprints/:sprintId/views/:viewId. func (h *ViewHandler) DeleteView(w http.ResponseWriter, r *http.Request) { projectID, err := parseProjectID(r) diff --git a/services/api/internal/transport/http/router/router.go b/services/api/internal/transport/http/router/router.go index b4d789c0a..c140f889b 100644 --- a/services/api/internal/transport/http/router/router.go +++ b/services/api/internal/transport/http/router/router.go @@ -559,6 +559,10 @@ func New(deps Deps) http.Handler { // permission to mutate the shared view. r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionViewsRead)). Put("/{viewId}/config", deps.View.UpdateMyViewConfig) + // Clearing a personal override needs no more privilege + // than setting one. + r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionViewsRead)). + Delete("/{viewId}/config", deps.View.ClearMyViewConfig) r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionViewsWrite)). Delete("/{viewId}", deps.View.DeleteView) r.With(httpmw.RequirePublicProjectOrPermissions(deps.ProjectVisibilitySvc, deps.Authorizer, diff --git a/services/api/migrations/000048_add_user_view_configs.sql b/services/api/migrations/000057_add_user_view_configs.sql similarity index 97% rename from services/api/migrations/000048_add_user_view_configs.sql rename to services/api/migrations/000057_add_user_view_configs.sql index fe2d6f2c7..560a3cd7a 100644 --- a/services/api/migrations/000048_add_user_view_configs.sql +++ b/services/api/migrations/000057_add_user_view_configs.sql @@ -1,4 +1,4 @@ --- 000042_add_user_view_configs.sql +-- 000057_add_user_view_configs.sql -- Per-user overrides for interaction view settings and filters. -- -- A sprint_views row holds the project-shared view definition (name, type, diff --git a/services/api/test/integration/view_test.go b/services/api/test/integration/view_test.go index 32ca88810..2cdfdb257 100644 --- a/services/api/test/integration/view_test.go +++ b/services/api/test/integration/view_test.go @@ -203,6 +203,13 @@ func (r *fakeViewRepoIT) UpsertUserViewConfig(_ context.Context, viewID, userID return nil } +func (r *fakeViewRepoIT) DeleteUserViewConfig(_ context.Context, viewID, userID uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.userConfigs, userViewCfgKey(viewID, userID)) + return nil +} + // --------------------------------------------------------------------------- // Router builder // ---------------------------------------------------------------------------