feat(dashboard): add kinds CSV query param to /api/dashboard/activity
#293
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Background
Deferred from PR #288 during review cycle 1 (Markus Keller).
The #285 issue body lists as backend scope: "Add
kindsquery param toGET /api/dashboard/activity(CSV of AuditKind, default = 6 kinds)."PR #288 ships client-side filtering in
/chronik(the 5 filter pills filter the already-loaded 40 items in-browser). That's acceptable for v1 but means narrow filters may show fewer items than available — e.g., switching to "Hochgeladen" with 2 uploads in the last 40 rows shows 2 rows even if the user has 50 uploads further back in time.Concern
Scope drift between issue spec and shipped code. Moving the filter to the server lets each pill request its own set of kinds and get up to 40 matching rows.
Scope
kindsquery param toDashboardController.getActivity(CSV parsed toSet<AuditKind>, default = all 6 rollable kinds).AuditLogQueryRepository.findRolledUpActivityFeed— parameterize theWHERE kind IN (...)clause.goto?filter=...already triggers the server load).Reference
🏛️ Markus Keller — Senior Application Architect
Observations
findRolledUpActivityFeedhardcodesWHERE a.kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED','BLOCK_REVIEWED','COMMENT_ADDED','MENTION_CREATED')in the innermost CTEevents. The filter needs to move outward (or be parameterized in place) without breaking the partial covering index's applicability.WHERE kind IN (six values)requires the query predicate to be a subset of those six. A query forkind IN ('TEXT_SAVED')still uses the index because the Postgres planner recognizes the subset relationship. That's the right shape here.+page.svelte:95-116. Moving the mapping to the server means the API contract is "which kinds do you want"; the presentation label ("Hochgeladen", "Transkription") stays frontend.merged.filter((i) => i.kind === 'MENTION_CREATED' || i.youMentioned). The second disjunct uses theyouMentionedflag derived from:currentUserId. Purekindsfiltering cannot express this.Recommendations
WHERE kind IN (...)at the events CTE level, not at the outer aggregation. Filtering ateventsmeans fewer rows enter the LAG/session CTEs — cheaper and keeps the index predicate subset-valid:kindsto express "Für dich". That filter composeskind=MENTION_CREATED OR youMentioned=TRUE. Keep it as a separateforYou: booleanquery param (or encode it in the existingfilteralias if you want a single knob).kindsstays a pure kind-set filter;forYouis its own predicate. Conflating them makes the API ambiguous.+page.server.ts, not+page.svelte. The pill value ("hochgeladen") is a UI token; mapping it to anAuditKindset is a server concern. That keeps the+page.sveltefocused on rendering and makes the filter spec testable at the server load layer.kindsis absent: all 6. Back-compat with dashboard side-rail callers that don't know about the new param.Open Decisions
forYou: booleanquery param alongsidekinds. Option B: dedicatedfilter: "fuer-dich"enum value at the controller that translates internally tokinds=MENTION_CREATED + forYou=true. Option C: keep "Für dich" as client-side-only filter (since the live SSE singleton already drives the real for-you box). Recommend C — "Für dich" is the only pill that pulls from notifications, not the activity feed, and the existing client-side predicate already works for the timeline row highlighting.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
@RequestParambindsSet<AuditKind>directly when the param is CSV (?kinds=TEXT_SAVED,FILE_UPLOADED) — no custom converter needed. Enum name validation happens automatically; a bad value returns 400.+page.svelte:95-116. Lifting it to+page.server.tsremoves reactive filter state from the component —activeFiltercan then be a simple$derived(data.filter)without the$state + $effect + eslint-disabledance I flagged in PR #288 review.ActivityFeedItemDTO.kindis typed as the fullAuditKindunion. Existing frontend filter predicates (i.kind === 'FILE_UPLOADED') compile fine against the same DTO shape post-change.Recommendations
AuditKind.ROLLUP_ELIGIBLE(or similar) as the single source of truth for the six-kind default. Today that list is duplicated in the SQL WHERE + V49 index + frontend filter — promoting to a Java constant trims one of the duplications.rolledUpFeed_filters_to_provided_kinds_only— insertTEXT_SAVED+FILE_UPLOADED, call withkinds=[FILE_UPLOADED], assert only the upload row.rolledUpFeed_returns_empty_when_kinds_set_is_empty— decide behavior: treat as "no match" or as "default all"? Felix vote: empty set = no match (returns empty list); onlynullmeans "use default". That's explicit and avoids ambiguous signaling.rolledUpFeed_still_uses_partial_index_for_kind_subsets— not a unit test per se; add anEXPLAIN ANALYZEcheck to the PR description or a quick@Test @Disabledwith the plan assertion.activity_parses_kinds_csv_into_set+activity_rejects_invalid_enum_value_with_400.displayFeedclient-side switch in+page.svelte— the server-loadeddata.activityFeedis already filtered. That also removes the wholeactiveFilter = $state + $effectblock, since the filter is now pure URL → load → render.npm run generate:api. Miss this and the Chronik load function has type errors on thekindsparam.Open Decisions
?kinds=(empty) returns empty list — explicit, maps to "I asked for nothing, I got nothing." Option B: empty set is equivalent to omitting the param, returns default. Option C: empty set is a 400 — "malformed request." Recommend A; it has one less special case than B, and 400 for empty is user-hostile.🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
AuditKindis a plain enum — Spring's@RequestParam Set<AuditKind>binding returns 400 on any value that isn't a member. No special validation needed on the controller, and no string ever reaches SQL directly.:currentUserIdas a named parameter — safe. Adding:kindsas a second named parameter via Spring Data'sCollectionbinding stays in the parameterized path. No string concatenation anywhere./api/dashboard/activitystays gated by@RequirePermission(Permission.READ_ALL)— the kinds filter narrows what the requester sees, never expands access.Recommendations
Set<AuditKind>, notStringorList<String>. The enum binding is the validation. AString-typed param means hand-rolling the CSV parse + validation + lowercasing, and the first bug lands in the regex.?kinds=TEXT_SAVED,evil_value, log:logger.info("... '" + badValue + "'")— that's the Log4Shell path. SLF4J{}is injection-proof.READ_ALLcannot usekindsto extract anything beyond what they can get with the default.kindsset at the controller at the enum size (6). A request with?kinds=X,X,X,X,X,X,X,X,... (10k repeats)would be trimmed by theSetcontract (duplicates dropped) — but Spring parses the CSV first, which could still allocate a long intermediate list. Not worth a custom validator; the URL parser caps at ~8KB anyway. Just flagging for awareness.Open Decisions
🧪 Sara Holt — Senior QA Engineer
Observations
AuditLogQueryRepositoryRolledUpTestalready has 6 tests — the 7th forkindsfiltering slots in without infrastructure changes.DashboardControllerTesthas@WebMvcTestwith mockedDashboardService— perfect for parsing/validation tests that don't need a real DB.chronik/page.server.spec.tsexists and mocks the API client. Adding "the load function passes the right kinds CSV per filter" is mechanical.Recommendations
TEXT_SAVEDonly, insert 10 TEXT_SAVED events within 2h, assert one rollup row with count=10. Proves the session grouping still composes with the kinds filter.+page.sveltestops filtering. No sense maintaining parallel coverage at both layers.+page.svelte.spec.tsfile does not exist yet (flagged in #290 too). This issue is a second chance to add it — wire the filter assertion in the same spec file #290 creates.Open Decisions
🏗️ Tobias Wendt — DevOps & Platform Engineer
Observations
kindsquery param ships, Micrometer's default URI-template tagger still templates this as/api/dashboard/activity(query params are stripped before the label is computed). Good — no explosion of per-kind-combination metrics.Recommendations
Index Scan using idx_audit_log_rollup, notSeq Scan on audit_log./api/dashboard/activityrequest. The dashboard side-rail continues to hit the default (all 6 kinds) at page load. Total traffic doesn't explode, but the request pattern broadens.Open Decisions
🎨 Leonie Voss — UX/Design Lead
Observations
role=radiogroup, arrow key navigation, 44 px min-height. This issue changes the data-fetch layer, not the pill component itself.youMentioned. If that filter moves server-side, theyouMentionedpredicate goes with it — meaning the for-you timeline rows become server-filtered, while the for-you box at the top stays driven by the live SSE notifications singleton. Two different sources, one visual affordance.Recommendations
aria-busy="true"on the<ChronikTimeline>wrapper during navigation, possibly with a 3-row skeleton after 200ms (debounced so fast navigations don't flash). The spec's "Mehr laden" skeleton pattern applies here by analogy.filter-emptyvariant needs to make sense: "Nichts in dieser Ansicht" works for both client-side-empty and server-side-empty. No copy change needed — just confirm the empty state still renders when the server returns[]for a narrow filter.aria-live. When the filter pill is clicked and the server responds with N rows, announce "X Einträge gefiltert" (or similar) to the polite-live region. Pill clicks without feedback are invisible to keyboard/screen-reader users. This is a standalone a11y improvement, not strictly in this issue's scope but worth piggybacking.Open Decisions
🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
Architecture
forYou: booleanquery param alongsidekinds.filter: "fuer-dich"enum at the controller that translates tokinds=MENTION_CREATED + forYou=trueinternally.i.youMentionedis a display flag not a permission boundary.API Design
kindsset semantics.?kinds=(empty CSV) returns an empty list — "I asked for nothing, I got nothing."🏛️ Markus Keller — Senior Application Architect (Round 2)
The first-round review covered the SQL parameterization and "Für dich" shape well. One structural gap was missed.
Observations
AuditLogQueryService.findActivityFeed()is called from two places, not one.DashboardService.getActivity()at line 112 should receive thekindsfilter.DashboardService.getPulse()at line 92 also callsauditLogQueryService.findActivityFeed(userId, 50)— that call must continue to receive all 6 rollable kinds regardless of any filter param.AuditLogQueryService.findActivityFeed()signature is changed to(UUID, int, Set<AuditKind>)without an overload, the pulse call site breaks at compile time. If the issue scope only changesgetActivity, the pulse path must still pass an explicit default.AuditLogQueryServiceis a thin delegation layer — it does no filtering logic itself. Thekindsset should flow through it unchanged, just likelimitdoes today.Recommendations
findActivityFeedinAuditLogQueryServiceso the existing no-kinds signature used bygetPulsekeeps working without change:DashboardService.getPulse()at line 92 calls the zero-kinds overload and is not touched.DashboardService.getActivity()calls the new overload. Clean separation.Collection<String>to a nativeIN (:kinds)clause — passSet<String>of enum names.AuditLogQueryRepositoryRolledUpTestcovers the repo directly. Add aDashboardServicePulseTestasserting the pulse path still callsfindActivityFeedwith all 6 kinds if one does not already exist.👨💻 Felix Brandt — Senior Fullstack Developer (Round 2)
The first round covered the controller signature,
ROLLUP_ELIGIBLEconstant, and frontend simplification. Two dead-code deletion issues and one existing-test-breakage were not flagged.Observations
feedFilters.tsandfeedFilters.test.tsbecome fully dead code after this PR.feedFilters.tsexportsfilterFeed()which is currently called from+page.svelte. Once the server handles filtering,filterFeedis unused. Both files should be deleted, not just abandoned.feedFilters.test.tshas 9 tests coveringfilterFeed— they will still pass but test behavior that no longer exists in production. Dead passing tests are a maintenance trap.The existing
page.server.spec.tstest will silently verify wrong behavior after the change. Line 31-34 asserts:Once the load function passes
kindsper filter, this assertion will fail for non-allefilters and will need updating. Don't add tests for the new behavior alongside the old test — rewrite the existing test to assert the correct call shape for each filter value.DashboardControllercurrent default islimit=7(line 38), not 40. The chronik load function overrides this by always passinglimit=40explicitly. The controller default only matters for callers that omit the param — the dashboard side-rail, presumably.Recommendations
feedFilters.tsandfeedFilters.test.tsin the same commit that removes thedisplayFeedclient-side switch from+page.svelte. One commit: remove client-side filtering, delete dead files. Makes the PR diff easier to review.page.server.spec.tsto cover the per-filter API call shape:+page.svelteusage ofdata.activityFeeddirect — no client-sidefilterFeed()call. The server-loaded feed is already filtered; the component just renders it.🔒 Nora "NullX" Steiner — Application Security Engineer (Round 2)
The first round covered the critical points: type the param as
Set<AuditKind>, parameterized SQL binding, and SLF4J logging. No new vulnerabilities introduced. One clarification worth adding.Observations
AuditLogQueryServiceis a thin pass-through — no security surface added there. The enum binding validation happens at the controller layer before anything reaches the service or repo.youMentionedandyouParticipatedflags are computed inside the SQL query based on:currentUserId, not based on the kinds filter. Adding akindsparameter tofindRolledUpActivityFeeddoes not affect the privacy boundary for these flags — they remain correctly scoped to the authenticated user regardless of which kinds are requested.fuer-dichfilter staying client-side (Markus's Option C from Round 1) meansyouMentionedandyouParticipatedare still returned in all API responses and filtered in the browser. This is acceptable — both flags are already personal to the requesting user, not cross-user data. No additional exposure.Recommendations
@RequirePermission(Permission.READ_ALL)on the class-level inDashboardControlleralready gates the endpoint;kindsnarrows but never widens that gate.DashboardControllerTest: verify that a user with onlyREAD_ALLcan call?kinds=TEXT_SAVEDand get 200, and a user withoutREAD_ALLgets 403. Thekindsparam should not create a privilege escalation path — confirm this explicitly.🧪 Sara Holt — Senior QA Engineer (Round 2)
Round 1 covered repo integration tests and controller parse/validation tests well. Two test lifecycle issues were missed.
Observations
feedFilters.test.ts(9 tests) coversfilterFeed()— a function that will be deleted. These tests will continue to pass after this PR because the function file still exists in isolation. But they test dead production behavior. Leaving them creates the illusion of coverage for code that no longer runs. They must be deleted in the same commit asfeedFilters.ts.page.server.spec.tsline 31 asserts{ params: { query: { limit: 40 } } }— nokindskey. After the load function is updated to passkindsper filter, any test that verifies theallecase will still pass (nokindsparam is correct foralle), but any test for non-allefilters will fail or be missing. The current test suite has NO test for non-default filter behavior — the new tests need to be added and the old test foralleupdated to confirmkindsis absent (not just unasserted).DashboardService.getPulse()callsfindActivityFeedwith no kinds filter — this path has no test coverage for the overloaded signature introduced by this issue. Add one test asserting thatgetPulse()still receives all 6 rollable kinds even when the new overload exists.Recommendations
feedFilters.test.tsatomically withfeedFilters.ts— same commit. The test file name should appear in the PR diff as a deletion, not remain as a green-but-dead suite.page.server.spec.ts(per filter):DashboardServiceTest.pulse_uses_all_rollup_eligible_kinds()to guard the getPulse path after the overloaded signature lands.🏗️ Tobias Wendt — DevOps & Platform Engineer (Round 2)
Round 1 noted no infrastructure surface changes and flagged the EXPLAIN ANALYZE check. One compilation-safety note for the service layer.
Observations
AuditLogQueryService.findActivityFeed()signature change affectsDashboardService.getPulse()at compile time if implemented naively. The Maven build (./mvnw clean package -DskipTests) will catch this — but only if someone runs it before the PR is merged. If the overloaded method approach is used (as Markus recommends), the build is safe. Worth verifying: run a full backend build as part of the PR checklist./api/dashboard/activityURI template is what Micrometer records; query params don't become label values. No Prometheus explosion risk.Recommendations
./mvnw clean package -DskipTeststo the PR checklist before opening review. The getPulse-vs-getActivity call site split is a compile-time risk — a full build catches it in seconds and prevents a CI failure from a caller that's easy to miss during code review.INclause before merge to confirm the V49 partial index is still chosen. This is a one-time manual check, not a CI gate.Collectionbinding for nativeINclauses has been stable since Spring Data 2.x.🎨 Leonie Voss — UX/Design Lead (Round 2)
Round 1 covered loading state, empty-state copy, and aria-live on filter change. One URL canonicalization detail affects the default filter UX.
Observations
allefilter must produce a clean/chronikURL, not/chronik?kinds=or/chronik?filter=alle. Looking at+page.server.ts, the load function currently readsurl.searchParams.get('filter')and defaults to'alle'. The navigation already works viagoto?filter=...— when the user returns toalle, the URL should drop thefilterparam entirely (or the pill should navigate to/chronikrather than/chronik?filter=alle). This keeps the canonical URL clean and avoids a jarring visual difference between a freshly loaded page and a "reset to all" click.fuer-dichfilter staying client-side (Markus Option C) has a subtle UX implication: after a hard reload on/chronik?filter=fuer-dich, the server returns all 40 items (no kinds filter sent), and the client-sidefilterFeedapplies theyouMentioned/youParticipatedpredicate. If the user has few mentions, the page may flash a full list then snap to a short one. This is cosmetically tolerable but worth calling out so the implementation is intentional, not accidental.[]for a narrow filter: Round 1 confirmed the copy"Nichts in dieser Ansicht"still works. Verify the empty state is rendered fromdata.activityFeed.length === 0directly — not fromfilterFeed(data.activityFeed, filter).length === 0. After this change, the server result is already filtered; no client-side predicate should gate the empty state for server-filtered pills.Recommendations
alle, navigate to/chronik(no query param) rather than/chronik?filter=alle. The pill component'shrefforalleshould be"/chronik"not"/chronik?filter=alle". The load function already defaults to'alle'when the param is absent — no server change needed.fuer-dichflash consciously: add a brief comment in the load function or the pill component noting thatfuer-dichis intentionally client-side filtered because it relies onyouMentioned/youParticipatedflags. This prevents a future developer from "fixing" it by adding a server-side path that can't express the same predicate.🎯 Discussion Resolutions
After reviewing the persona feedback with the user, here are the agreed decisions:
Theme 1 — Service layer:
AuditLogQueryServicedual-caller splitfindActivityFeedinAuditLogQueryService. The existing two-arg signature(UUID, int)stays in place forgetPulse— untouched. A new three-arg signature(UUID, int, Set<AuditKind>)is used bygetActivity.getPulsemust always receive all 6 rollable kinds;getActivityreceives whatever the filter pill requests. Overloading keeps both call sites clean without special-casing../mvnw clean package -DskipTestsbefore opening the PR to catch any compile-time breakage.DashboardServiceTest.pulse_uses_all_rollup_eligible_kinds()to guard the getPulse path.Theme 2 — SQL: WHERE clause parameterization and index safety
WHERE a.kind IN (:kinds)at theeventsCTE level (innermost), not the outer aggregation.eventsmeans fewer rows enter the LAG/session CTEs. The V49 partial index remains applicable — Postgres recognizes a subset predicate.idx_audit_log_rollupis chosen, not a seq scan.Theme 3 — Controller API design
@RequestParam(required = false) Set<AuditKind> kinds. IntroduceAuditKind.ROLLUP_ELIGIBLEas aSet<AuditKind>constant of the 6 eligible kinds. Whenkindsis null, default toROLLUP_ELIGIBLE.ROLLUP_ELIGIBLEeliminates one of the three duplications of the six-kind list. Null default keeps existing callers (dashboard side-rail) working without changes.{}placeholder — never string concatenation.Theme 4 — Empty
kindsset semanticskinds(e.g.?kinds=) is treated the same as a missing param — returns default all-6.Theme 5 — "Für dich" filter shape
kindsparam sent to the server; the server returns all 40 rows; the client appliesyouMentioned || youParticipatedpredicate.kindsfilter.Theme 6 — Dead code deletion
feedFilters.tsandfeedFilters.test.tsin the same commit that removes thefilterFeed()call from+page.svelte.filterFeed()is unused.feedFilters.test.ts(9 tests) would continue passing but test behavior that no longer exists in production — a maintenance trap.Theme 7 — Frontend
+page.server.tssimplification+page.server.ts(server concern, not rendering concern). Confirmed mapping:alle→ omitkindsparam (server defaults to all 6)hochgeladen→['FILE_UPLOADED']transkription→['TEXT_SAVED', 'BLOCK_REVIEWED', 'ANNOTATION_CREATED']kommentare→['COMMENT_ADDED', 'MENTION_CREATED']fuer-dich→ omitkindsparam (client-side predicate)activeFilter = $state + $effectblock from+page.svelte— filter is pure URL → load → render./chronik(no query param) for a clean canonical URL.Theme 8 — Existing test breakage
page.server.spec.tstest (line 31) rather than adding alongside it. Explicitly assertkindsis absent foralle; add one test per non-alle filter asserting the exact kinds CSV.Theme 9 — New test coverage
Backend repo (
AuditLogQueryRepositoryRolledUpTest):rolledUpFeed_with_single_kind_returns_only_that_kindrolledUpFeed_with_multiple_kinds_returns_unionrolledUpFeed_with_default_returns_all_six_eligible_kindsrolledUpFeed_excludes_rows_not_in_filter_setrolledUpFeed_rollup_still_works_when_kind_set_is_filtered_to_single_rollable_kindBackend controller (
DashboardControllerTest):activity_parses_kinds_csv_into_setactivity_returns_400_when_kinds_contains_invalid_enum_valueactivity_uses_default_set_when_kinds_param_absentactivity_treats_empty_kinds_as_defaultactivity_with_kinds_returns_403_when_user_lacks_READ_ALLBackend service (
DashboardServiceTest):pulse_uses_all_rollup_eligible_kindsFrontend (
page.server.spec.ts): per-filter kinds assertions (alle, hochgeladen, transkription, kommentare, fuer-dich).Theme 10 — UX/A11y
aria-busy="true"on the timeline wrapper during SvelteKit navigation; optional 3-row skeleton after 200ms (debounced). Empty state rendered fromdata.activityFeed.length === 0directly (not fromfilterFeedresult). Addaria-live="polite"announcement on filter change.Theme 11 — Operational
These resolutions now act as the authoritative design for implementation. The
implementskill will read this comment alongside the original issue.