Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
type Task,
type TaskListResult,
taskQueryOptions,
updateMyViewConfig,
updateSprint,
updateTask,
updateViewById,
Expand Down Expand Up @@ -473,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 }))
Expand Down Expand Up @@ -1569,9 +1573,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 });
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/lib/interaction-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<InteractionView> {
const { data } = await apiClient.instance.put<
SuccessEnvelope<Omit<InteractionView, "layout">>
>(`/projects/${projectId}/views/${viewId}/config`, { config });
return mapView(data.data);
}

export async function deleteViewById(
projectId: string,
viewId: string,
Expand Down
9 changes: 9 additions & 0 deletions services/api/internal/domain/sprint/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions services/api/internal/domain/sprint/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,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.
Expand Down
66 changes: 66 additions & 0 deletions services/api/internal/repository/postgres/view_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions services/api/internal/service/sprint/cached_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
15 changes: 15 additions & 0 deletions services/api/internal/service/sprint/cached_view_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
57 changes: 57 additions & 0 deletions services/api/internal/service/sprint/view_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,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 {
Expand Down
Loading