Skip to content

fix(views): make view settings and filters per-user instead of global - #434

Open
vhervatin wants to merge 4 commits into
Paca-AI:masterfrom
vhervatin:fix/per-user-view-config
Open

fix(views): make view settings and filters per-user instead of global#434
vhervatin wants to merge 4 commits into
Paca-AI:masterfrom
vhervatin:fix/per-user-view-config

Conversation

@vhervatin

Copy link
Copy Markdown
Contributor

Summary

View settings and filters were global: any member changing the "View settings" panel (sort, field sum, page size, visible fields, collapsed columns, and every filter dimension) overwrote what all other members saw.

They were stored in the single shared config JSONB column of the sprint_views row, keyed only by project + context — there was no user_id anywhere in the view entity/table/repo/service/handler. The project-wide view.updated broadcast then pushed that change to every connected member.

This PR keeps views as shared project entities (name, type, position, and a shared default config) but makes settings/filters per-user:

  • New user_view_configs table keyed by (view_id, user_id), ON DELETE CASCADE to both sprint_views and users.
  • Personal config is overlaid on read — 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.
  • New endpoint PUT /projects/{projectId}/views/{viewId}/config writes only the current user's override and emits no project-wide event, so a member's tweaks stay private. It requires sprints:read (not write), so a read-only member can still sort/filter their own view.
  • The shared PATCH /views/{viewId} (name/type/position/shared default) is unchanged; the web client now routes settings-panel saves and column-collapse to the per-user endpoint. Realtime view.* events already invalidate the views query, so a structural change by another user refetches and re-applies the caller's own effective config.

Migration only adds a table; no existing data is changed (everyone keeps seeing today's shared config until they personalize).

Type of Change

  • Bug fix (per-user view settings/filters instead of global)

Testing

  • New service unit tests prove isolation: an override applies only to its owner, other users keep the shared default, wrong-project is rejected, and a nil user is a no-op.
  • go build ./..., gofmt, go vet clean; migration applied and the upsert/select + endpoint verified against a live Postgres.
  • Web tsc -b && vite build clean; existing realtime test passes.

Checklist

  • The change is focused and scoped.
  • Views stay shared; only per-user settings/filters are separated.
  • Cache correctness preserved (overlay runs after the shared cache).
  • Backwards compatible (additive migration, no data migration needed).

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 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown

Greptile Summary

The PR moves view settings and filters from shared view configuration to per-user overrides while retaining shared structural view properties.

  • Adds per-user configuration persistence keyed by view and user.
  • Overlays personal configuration after shared cache reads.
  • Routes web settings updates through the authenticated personal-config endpoint.
  • Rejects omitted or null configuration payloads before persistence.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported silent reset is prevented by rejecting omitted or null configuration before conversion or persistence.

Important Files Changed

Filename Overview
services/api/internal/transport/http/handler/view_handler.go Adds the authenticated personal-config endpoint and now rejects omitted or null config before conversion and persistence.
services/api/internal/transport/http/dto/view_dto.go Defines the personal-config request DTO and conversion into the domain configuration.
services/api/internal/service/sprint/view_service.go Persists user-scoped overrides and overlays them onto per-request view objects.
services/api/internal/repository/postgres/view_repository.go Implements bulk retrieval and upsert operations for per-user view configurations.
services/api/migrations/000042_add_user_view_configs.sql Adds the user-view configuration table, composite key, cascading foreign keys, and user lookup index.
apps/web/src/components/projects/interactions/interaction-layout.tsx Redirects settings, filters, and default-filter seeding from shared view updates to personal configuration updates.
apps/web/src/lib/interaction-api.ts Adds the client call for replacing the authenticated user's personal view configuration.

Sequence Diagram

sequenceDiagram
    participant Web
    participant Handler
    participant ViewService
    participant SharedCache
    participant UserConfigRepo

    Web->>Handler: "PUT /projects/{projectId}/views/{viewId}/config"
    Handler->>Handler: Require authenticated user and non-null config
    Handler->>ViewService: SetUserViewConfig(project, view, user, config)
    ViewService->>UserConfigRepo: Upsert (view_id, user_id)
    UserConfigRepo-->>ViewService: Stored personal override
    ViewService-->>Web: View with personal config

    Web->>Handler: GET views
    Handler->>SharedCache: Read shared views
    SharedCache-->>Handler: Shared view definitions
    Handler->>UserConfigRepo: Read current user's overrides
    UserConfigRepo-->>Handler: Personal configs
    Handler-->>Web: Views with personal configs overlaid
Loading

Reviews (2): Last reviewed commit: "fix(views): implementa fake repo de inte..." | Re-trigger Greptile

Comment on lines +32 to +38
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing config erases settings

When an authenticated member sends an omitted or null config for a non-plugin view, ToViewConfig converts it to an empty configuration and the handler persists it, silently resetting that user's saved settings and filters instead of rejecting the request.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • New user_view_configs table (000042) keyed (view_id, user_id) with ON DELETE CASCADE to views and users, plus a user_id index.
  • Per-user overlay on readOverlayUserConfigs applies a user's personal config after the shared cache read in ListViews/GetView, leaving nil-user (public) readers on the shared default.
  • New PUT /projects/{projectId}/views/{viewId}/config (SetUserViewConfig) writes only the caller's override, checks project ownership, and emits no project-wide event; gated by sprints:read.
  • Web routing — settings-panel saves and column collapse now go through updateMyViewConfig; the full effective config is always sent.
  • Tests — 5 new service unit tests prove isolation (override applies only to its owner, other users keep the shared default, wrong-project rejected, nil user no-op).

I verified the cache-pollution concern directly: cache.Store JSON-serializes and each Get (store.go:29) re-unmarshals fresh objects, so OverlayUserConfigs mutating the returned view slice in place cannot pollute the shared cache — the stored bytes are never touched. The migration numbering is sequential (000041 precedes it) and the test assertions are exact (they genuinely fail without the fix).

Two design-level observations below; neither blocks merge and both are for you/the product owner to weigh, not require a change.

ℹ️ Personal config snapshots don't receive later shared-default changes

The overlay replaces a view's Config wholesale with the user's stored override, and the client stores a full snapshot on every write. Once any member personalizes, a later project-wide change to the shared default (a field added to visible_fields project-wide, a shared group_by/page size set by an owner via the unaffected PATCH /views/{viewId}) will not surface for that member — their snapshot hides the new shared fields. If the intent is "shared default = baseline everyone inherits until they customize," a field-level merge on read (override wins per field, shared fills the rest) would preserve that; a full replace means personalization effectively freezes the config at the moment of the first edit. This is a real product tradeoff worth confirming.

Technical details
# Shared-default divergence after personalization

## Affected sites
- services/api/internal/service/sprint/view_service.go — `OverlayUserConfigs` sets `v.Config = cfg` (wholesale) when an override exists; `SetUserViewConfig` stores the full config it is handed.
- apps/web/src/components/projects/interactions/interaction-layout.tsx — both settings-panel and column-collapse writes send the full `activeView.config` snapshot.

## Required outcome
- Decide and confirm whether per-user config should be a full snapshot (current) or a field-level overlay on top of the evolving shared default. Field-merge keeps future shared-default changes reaching personalized users; snapshot does not.

ℹ️ Default-filters init still writes derived defaults to the shared row

interaction-layout.tsx (updateViewById(projectId, view.id, { config }) in the effect that seeds default filters for views with filters === undefined) still targets the shared PATCH /views/{viewId}, not the per-user endpoint. The config it persists is derived from the current user's defaultPageTaskTypeIds, so this path re-introduces exactly the "one member's setup leaks to all" behavior the PR sets out to remove. It only fires when the effective filters is undefined, but once it does it writes one user's derived defaults into the shared row for everyone. This code is untouched by the PR, but it sits directly in tension with the new per-user premise — worth confirming it isn't accidentally re-seeding the shared config.

Technical details
# Per-user derived filters still seeded into the shared row

## Affected sites
- apps/web/src/components/projects/interactions/interaction-layout.tsx — default-filters initialization effect calls `updateViewById` (shared PATCH) with `buildDefaultViewConfig` output.

## Required outcome
- Confirm whether view-default filter seeding should also go to the per-user config now that settings/filters are per-user; if not, no change needed.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@vhervatin

Copy link
Copy Markdown
Contributor Author

@pikann what do you think about I developer a gantt view project? A new type view in a projet!

@pikann

pikann commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Hi @vhervatin

Thanks for the great contribution!

The per-user view setting is a solid feature, it nicely solves the issue of users wanting to filter or adjust settings without affecting others.

Overall, the PR looks good to me. A couple of suggestions:

  • "Save for Everyone" button: Consider adding a "Save for Everyone" option so users can apply settings globally when needed.
  • Reset button behavior: The "Reset" button should clear all per-user view settings so the user reverts to the global default view.

Regarding your question about the Gantt view: To answer your thought on making it a new view type, I think it would be a great fit if we implement/integrate it inside the Roadmap view instead.

Thanks again for your hard work! <3

@pikann

pikann commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Also, looking ahead, we could add a toggle/switch in the view settings to let users explicitly set a view as "Personal View"

…o config

- 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 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Default-filters seeding now targets the per-user override — the init effect in interaction-layout.tsx (buildDefaultViewConfig + the previously-flagged updateViewById) now calls updateMyViewConfig, so a member's derived default filters land in their personal config instead of leaking into the shared sprint_views row. I confirmed buildDefaultViewConfig spreads the shared config as its base, so the derived-filters snapshot still carries the existing shared fields (no field loss on overlay). This addresses the prior "one member's setup leaks to all" concern.
  • Nil-config guard on the per-user PUTUpdateMyViewConfig now rejects an omitted/null config with a 400. Since a PUT wholesale-replaces the stored override, this prevents an empty body from silently wiping a user's saved settings. toViewConfig(nil) already yields an empty config, so the guard is the real protection; the web client always sends a non-nil full config, so no false positives.
  • Integration fake repo extendedfakeViewRepoIT gained a userConfigs store plus GetUserViewConfigs/UpsertUserViewConfig to satisfy the widened repository interface. Pure test scaffolding.

The changes are tightly scoped and correctly close the loop on the per-user config design from the initial review.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Resolve conflict in view_service_test.go: keep both the per-user view
config tests and upstream's cross-project isolation tests; update the
per-user tests to the new 4-arg NewViewService(repo, sprintRepo,
taskRepo, publisher) signature.

Renumber migration 000042_add_user_view_configs.sql -> 000048 to avoid
collision with upstream's new 000042_add_environments.sql.
@vhervatin

Copy link
Copy Markdown
Contributor Author

Rebased onto the latest master (v0.14.1) and pushed the resolution — the branch is mergeable again.

What was conflicting

The branch predated the v0.14.1 changes that touched the same view code:

  1. services/api/internal/service/sprint/view_service_test.go — a real content conflict between this PR's per-user view-config tests and the new cross-project isolation tests. I kept both sets, and updated the per-user tests to the new NewViewService(repo, sprintRepo, taskRepo, publisher) 4-arg signature.
  2. Migration number collision (not flagged by git since the filenames differ): this PR's 000042_add_user_view_configs.sql clashed with the new 000042_add_environments.sql. I renumbered ours to 000048_add_user_view_configs.sql.

Validation (Go 1.26, against the merged tree): go build ./..., go vet, and go test ./internal/service/sprint/... all pass; the integration test package compiles and the handler tests pass. No conflict markers remain.

The only pending check is the fork-PR workflow gate (action_required), which needs a maintainer to approve the run.

Question: is there anything else you'd like changed on our side before this can be merged? Happy to adjust the resolution, squash, or rebase differently if that fits your workflow better.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Merge with upstream/master (v0.14.1) is clean — one cosmetic nit inline.

Reviewed changes

  • Rebased onto upstream/master (v0.14.1) — the per-user view config feature (overlay-on-read, per-user SetUserViewConfig, the PUT .../config endpoint, web routing, user_view_configs table) carries through the merge intact. Verified via go build ./..., go vet, and the sprint / handler / postgres repository test suites.
  • Adapted NewViewService call sites — master's cross-project hardening added sprintRepo/taskRepo params (ownership checks in ListViews/CreateView/ReorderViews/MoveTask/BulkMoveTasks); the per-user tests now inject permissive fakes and app.go:160 wires the real repos.
  • Renumbered migration 000042000048 — correctly slotted after master's new 000042000047; embed.go picks it up automatically via //go:embed *.sql.

ℹ️ Nitpicks

  • services/api/migrations/000048_add_user_view_configs.sql:1 — the doc header still reads -- 000042_add_user_view_configs.sql after the rename; sibling migrations (000045–000047) all use the matching filename in their header comment.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@@ -0,0 +1,30 @@
-- 000042_add_user_view_configs.sql

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale doc header after the rename: the file is now 000048_add_user_view_configs.sql, but this comment still says 000042. Sibling migrations (000045–000047) all open with a header matching their filename, so this one is inconsistent.

MoveTask/BulkMoveTasks passaram a validar ownership da task (fix upstream
0cf5129), 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 <noreply@anthropic.com>
@vhervatin

Copy link
Copy Markdown
Contributor Author

CI update — E2E was red due to a pre-existing test/behavior mismatch on master; pushed a fix

Thanks for approving the workflow run. 5 of 6 checks are green (Build, Web, Lint, Unit & integration, pullfrog). The E2E job failed, and after digging in I found the cause is not in this PR's code — it's inherited from master:

Root cause

Commit 0cf5129d ("apply the same project-ownership check across tasks, sprints/views, automations, and agents") made MoveTask/BulkMoveTasks verify task ownership via FindTaskByID, so an unknown task_id now returns 404. That commit did not update services/api/test/e2e/view_management_test.go, which still moves non-existent task UUIDs and asserts 204 (the test even comments "doesn't need to exist in DB").

So the four position E2E tests (TestE2ETaskPositionManagement, TestE2EBacklogTaskPositionManagement, TestE2EBulkTaskPositionManagement, TestE2EBulkBacklogTaskPositionManagement) fail with expected 204, got 404 on the merge with v0.14.1. This PR doesn't touch the position code — MoveTask/BulkMoveTasks/router are byte-identical to master; my changes only add the per-user config path.

What I pushed

I updated those four E2E tests to create real tasks via the API before moving them (the tasks now exist and belong to the project, satisfying the new ownership check). Test-only change; the test/e2e package compiles clean. It's a small, self-contained fix that also gets master's E2E green for any PR that merges it.

If you'd rather keep this PR scoped strictly to the per-user view config, I'm happy to pull the test fix out into its own PR against master — just let me know.

The only remaining gate is the fork-PR workflow approval (action_required). 🙏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues in this delta — a test-only fix, correctly scoped.

Reviewed changes

  • Adapted the four view-position e2e tests to create real tasksMoveTask/BulkMoveTasks now enforce task ownership (upstream hardening 0cf5129), so the previously-faked task UUIDs would 404 instead of the asserted 204. Each test now seeds its tasks via createTaskViaAPI in its own project and view, keeping the existing position-count and task-id echo assertions unchanged.

The swap is consistent with the ownership check: tasks are created in the same project by the same member, each test uses a dedicated project/view, and per-view assertions (single position, 3 positions after bulk, upsert overwrite) are unaffected. The prior migration-header nit (-- 000042 comment in 000048_add_user_view_configs.sql) remains open and unchanged.

Pullfrog  | Fix it ➔View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pikann

pikann commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Hi @vhervatin ,
This is a great PR with good ideas, but I'd like to make a few related UX adjustments.
May I push these changes directly to this PR?

@vhervatin

Copy link
Copy Markdown
Contributor Author

Hi @pikann — yes, please go ahead and push your UX adjustments directly to the branch. Edits from maintainers are enabled, so you're all set. Happy to have them, and I'll keep my side in sync. 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants