refactor(admin): dedicated /api/admin/stats endpoint returning counts only #453

Open
opened 2026-05-07 09:24:48 +02:00 by marcel · 8 comments
Owner

Context

The admin layout (frontend/src/routes/admin/+layout.server.ts) currently fetches full entity lists (/api/users, /api/groups, /api/tags) solely to display counts on the System stats panel. It loads data it does not render.

Problem

  • Over-fetching: entire user/group/tag lists are loaded when only .length is used
  • Unnecessary backend load on every admin page navigation
  • Layering concern: the frontend drives three parallel full-list fetches for a single stats widget

Acceptance criteria

  • New GET /api/admin/stats endpoint returning { userCount, groupCount, tagCount } (counts only)
  • @RequirePermission(Permission.ADMIN) on the new endpoint
  • Admin layout switches to the new stats endpoint — removes the three full-list fetches
  • Existing admin management pages (users, groups, tags) continue to fetch full lists from their own routes, not the layout
  • Backend test covers the stats endpoint response shape
  • npm run generate:api run after backend change

Notes

This is a tracked backlog item extracted from a TODO comment in +layout.server.ts:29 during CLEANUP-2 (#413).

## Context The admin layout (`frontend/src/routes/admin/+layout.server.ts`) currently fetches full entity lists (`/api/users`, `/api/groups`, `/api/tags`) solely to display counts on the System stats panel. It loads data it does not render. ## Problem - Over-fetching: entire user/group/tag lists are loaded when only `.length` is used - Unnecessary backend load on every admin page navigation - Layering concern: the frontend drives three parallel full-list fetches for a single stats widget ## Acceptance criteria - [ ] New `GET /api/admin/stats` endpoint returning `{ userCount, groupCount, tagCount }` (counts only) - [ ] `@RequirePermission(Permission.ADMIN)` on the new endpoint - [ ] Admin layout switches to the new stats endpoint — removes the three full-list fetches - [ ] Existing admin management pages (users, groups, tags) continue to fetch full lists from their own routes, not the layout - [ ] Backend test covers the stats endpoint response shape - [ ] `npm run generate:api` run after backend change ## Notes This is a tracked backlog item extracted from a TODO comment in `+layout.server.ts:29` during CLEANUP-2 (#413).
marcel added the P3-laterrefactor labels 2026-05-07 09:25:04 +02:00
Author
Owner

👨‍💻 Markus Keller — Application Architect

Observations

  • The TODO comment at +layout.server.ts:29 is correctly identified — three JpaRepository.findAll() calls are being made solely to .length the result. This is a textbook over-fetch.
  • The existing AdminController at /api/admin already uses @RequirePermission(Permission.ADMIN) at class level. The new GET /api/admin/stats endpoint fits cleanly into that controller rather than needing a new file, but the issue specifies Permission.ADMIN while the layout gate checks ADMIN | ADMIN_USER | ADMIN_TAG | ADMIN_PERMISSION. A user with only ADMIN_TAG would be blocked from the new endpoint — a layering mismatch.
  • The existing StatsController at /api/stats uses Permission.READ_ALL and serves person/document counts for the dashboard. The new endpoint serves admin-level entity counts. These are different audiences and different permission levels — keeping them separate is correct; do not add userCount/groupCount/tagCount to StatsDTO.
  • AppUserRepository, UserGroupRepository, and TagRepository all extend JpaRepository which already exposes count() (a single SELECT COUNT(*)) without any new query declaration needed. The implementation is trivially cheap.
  • No new Flyway migration is needed — this is pure application code change. No DB diagram updates required.

Recommendations

  • Place GET /api/admin/stats in the existing AdminController but lower the permission to Permission.ADMIN_USER on the method (not class level) — or create a minimal new AdminStatsController with a looser class-level permission that covers the full set of admin variants (ADMIN, ADMIN_USER, ADMIN_TAG, ADMIN_PERMISSION). The cleanest fit: new record AdminStatsDTO(long userCount, long groupCount, long tagCount) in the user package (where user/group admin lives), wired to a AdminStatsService that calls userRepository.count(), groupRepository.count(), tagRepository.count(). The controller gets @RequirePermission(Permission.ADMIN_USER) but that still blocks ADMIN_TAG-only users from seeing the tag count in nav. Worth discussing with Elicit.
  • CLAUDE.md does not need updating for a new method inside an existing controller, but if AdminStatsController is created as a new class, the package table does not need updating since it stays within user/.
  • Do not add userCount/groupCount/tagCount to the existing StatsDTO — those fields serve the dashboard (persons/documents). Mixing admin operational counts with dashboard analytics counts creates a DTO that serves two masters.
  • The +layout.server.ts spec test will need updating: the mock currently wires up three api.GET(...) calls for users/groups/tags; after this change it becomes one call for /api/admin/stats plus the invite fetch. Update the mock accordingly — do not leave the three-call mock in place.

Open Decisions

  • Permission level on the new endpoint: Permission.ADMIN (maintenance-only admins see counts) vs Permission.ADMIN_USER (user admins see counts, but tag-only admins still blocked) vs a new composite check. The layout currently gates the entire admin area on any admin permission, so the stats endpoint must be accessible to any admin variant — consider a dedicated Permission.ADMIN_STATS or accept that any authenticated admin can read counts (data sensitivity is low: counts only, no names).
  • Which controller: inline into AdminController (simpler, but mixes maintenance actions with read endpoints) vs a new AdminStatsController (cleaner SRP but more files). Given the project's feature-package principle, both land in user/ — preference is a new controller.
## 👨‍💻 Markus Keller — Application Architect ### Observations - The TODO comment at `+layout.server.ts:29` is correctly identified — three `JpaRepository.findAll()` calls are being made solely to `.length` the result. This is a textbook over-fetch. - The existing `AdminController` at `/api/admin` already uses `@RequirePermission(Permission.ADMIN)` at class level. The new `GET /api/admin/stats` endpoint fits cleanly into that controller rather than needing a new file, **but** the issue specifies `Permission.ADMIN` while the layout gate checks `ADMIN | ADMIN_USER | ADMIN_TAG | ADMIN_PERMISSION`. A user with only `ADMIN_TAG` would be blocked from the new endpoint — a layering mismatch. - The existing `StatsController` at `/api/stats` uses `Permission.READ_ALL` and serves person/document counts for the dashboard. The new endpoint serves admin-level entity counts. These are different audiences and different permission levels — keeping them separate is correct; do not add `userCount`/`groupCount`/`tagCount` to `StatsDTO`. - `AppUserRepository`, `UserGroupRepository`, and `TagRepository` all extend `JpaRepository` which already exposes `count()` (a single `SELECT COUNT(*)`) without any new query declaration needed. The implementation is trivially cheap. - No new Flyway migration is needed — this is pure application code change. No DB diagram updates required. ### Recommendations - Place `GET /api/admin/stats` in the existing `AdminController` but **lower the permission to `Permission.ADMIN_USER`** on the method (not class level) — or create a minimal new `AdminStatsController` with a looser class-level permission that covers the full set of admin variants (`ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`). The cleanest fit: new `record AdminStatsDTO(long userCount, long groupCount, long tagCount)` in the `user` package (where user/group admin lives), wired to a `AdminStatsService` that calls `userRepository.count()`, `groupRepository.count()`, `tagRepository.count()`. The controller gets `@RequirePermission(Permission.ADMIN_USER)` but that still blocks `ADMIN_TAG`-only users from seeing the tag count in nav. Worth discussing with Elicit. - `CLAUDE.md` does not need updating for a new method inside an existing controller, but if `AdminStatsController` is created as a new class, the package table does not need updating since it stays within `user/`. - Do not add `userCount`/`groupCount`/`tagCount` to the existing `StatsDTO` — those fields serve the dashboard (persons/documents). Mixing admin operational counts with dashboard analytics counts creates a DTO that serves two masters. - The `+layout.server.ts` spec test will need updating: the mock currently wires up three `api.GET(...)` calls for users/groups/tags; after this change it becomes one call for `/api/admin/stats` plus the invite fetch. Update the mock accordingly — do not leave the three-call mock in place. ### Open Decisions - **Permission level on the new endpoint**: `Permission.ADMIN` (maintenance-only admins see counts) vs `Permission.ADMIN_USER` (user admins see counts, but tag-only admins still blocked) vs a new composite check. The layout currently gates the entire admin area on any admin permission, so the stats endpoint must be accessible to any admin variant — consider a dedicated `Permission.ADMIN_STATS` or accept that any authenticated admin can read counts (data sensitivity is low: counts only, no names). - **Which controller**: inline into `AdminController` (simpler, but mixes maintenance actions with read endpoints) vs a new `AdminStatsController` (cleaner SRP but more files). Given the project's feature-package principle, both land in `user/` — preference is a new controller.
Author
Owner

👨‍💻 Felix Brandt — Fullstack Developer

Observations

  • The current +layout.server.ts does three parallel Promise.all fetches then calls .length on each result. All three responses are otherwise discarded — the full entity arrays are never passed to the Svelte component. The TODO comment at line 29 correctly flags this.
  • JpaRepository.count() already exists on all three repositories (AppUserRepository, UserGroupRepository, TagRepository) — no custom @Query is needed.
  • The frontend layout.server.spec.ts at line 9 mocks three sequential GET calls in order (users → groups → tags). After the refactor this mock signature changes: one GET for /api/admin/stats replaces the three. The existing test at line 52 (returns entity counts and permission flags for a full admin) will need its mock and assertions updated.
  • The EntityNav.svelte component receives userCount, groupCount, tagCount as individual number props — this interface does not need to change after the refactor, only how the layout server populates those props.
  • The invite count fetch uses raw fetch (not the typed API client) because it's via the internal URL. That fetch is separate and stays as-is — only the three typed API client calls are being replaced.

Recommendations

  • Backend: Create record AdminStatsDTO(long userCount, long groupCount, long tagCount) — use a Java record, not a Lombok class, consistent with the existing StatsDTO record pattern. Place it in the user package alongside the other admin types.
  • Backend service: AdminStatsService.getAdminStats() — one public method, under 10 lines, calls userRepository.count(), groupRepository.count(), tagRepository.count(). Annotate @Service @RequiredArgsConstructor. No @Transactional — these are reads.
  • Backend controller: add GET /api/admin/stats returning AdminStatsDTO. If adding to AdminController: remove the class-level @RequirePermission(Permission.ADMIN) guard on the method and add @RequirePermission at method level only (so the maintenance actions keep their ADMIN permission and the stats method gets a lighter one). Cleaner: a new AdminStatsController.
  • Frontend: Replace the three-call Promise.all block with a single api.GET('/api/admin/stats') call. Run npm run generate:api after backend changes — the new endpoint must appear in the OpenAPI spec before the frontend types will compile.
  • Tests: Write AdminStatsControllerTest first (red → green): verify 401 unauthenticated, 403 without admin permission, 200 with correct JSON shape { userCount, groupCount, tagCount }. Update layout.server.spec.ts mock from three GET calls to one.
  • TDD order: backend controller test → service test → implementation → generate:api → frontend load function test update → implementation update.

Open Decisions

  • None — the implementation path is clear once the permission question is resolved.
## 👨‍💻 Felix Brandt — Fullstack Developer ### Observations - The current `+layout.server.ts` does three parallel `Promise.all` fetches then calls `.length` on each result. All three responses are otherwise discarded — the full entity arrays are never passed to the Svelte component. The TODO comment at line 29 correctly flags this. - `JpaRepository.count()` already exists on all three repositories (`AppUserRepository`, `UserGroupRepository`, `TagRepository`) — no custom `@Query` is needed. - The frontend `layout.server.spec.ts` at line 9 mocks three sequential `GET` calls in order (users → groups → tags). After the refactor this mock signature changes: one `GET` for `/api/admin/stats` replaces the three. The existing test at line 52 (`returns entity counts and permission flags for a full admin`) will need its mock and assertions updated. - The `EntityNav.svelte` component receives `userCount`, `groupCount`, `tagCount` as individual number props — this interface does not need to change after the refactor, only how the layout server populates those props. - The invite count fetch uses raw `fetch` (not the typed API client) because it's via the internal URL. That fetch is separate and stays as-is — only the three typed API client calls are being replaced. ### Recommendations - **Backend**: Create `record AdminStatsDTO(long userCount, long groupCount, long tagCount)` — use a Java `record`, not a Lombok class, consistent with the existing `StatsDTO` record pattern. Place it in the `user` package alongside the other admin types. - **Backend service**: `AdminStatsService.getAdminStats()` — one public method, under 10 lines, calls `userRepository.count()`, `groupRepository.count()`, `tagRepository.count()`. Annotate `@Service @RequiredArgsConstructor`. No `@Transactional` — these are reads. - **Backend controller**: add `GET /api/admin/stats` returning `AdminStatsDTO`. If adding to `AdminController`: remove the class-level `@RequirePermission(Permission.ADMIN)` guard on the method and add `@RequirePermission` at method level only (so the maintenance actions keep their `ADMIN` permission and the stats method gets a lighter one). Cleaner: a new `AdminStatsController`. - **Frontend**: Replace the three-call `Promise.all` block with a single `api.GET('/api/admin/stats')` call. Run `npm run generate:api` after backend changes — the new endpoint must appear in the OpenAPI spec before the frontend types will compile. - **Tests**: Write `AdminStatsControllerTest` first (red → green): verify 401 unauthenticated, 403 without admin permission, 200 with correct JSON shape `{ userCount, groupCount, tagCount }`. Update `layout.server.spec.ts` mock from three GET calls to one. - **TDD order**: backend controller test → service test → implementation → `generate:api` → frontend load function test update → implementation update. ### Open Decisions - None — the implementation path is clear once the permission question is resolved.
Author
Owner

👨‍💻 Tobias Wendt — DevOps & Platform Engineer

Observations

  • This is a pure application-layer refactor: no new Docker services, no new infrastructure, no Flyway migrations. The operational footprint is unchanged.
  • The change reduces backend load on every admin page navigation — three full-list queries become one count query. At the current scale this is negligible, but the pattern (fetching lists to count them) is the kind of thing that would show up under load as an N+1 in disguise. Worth fixing before the user/group/tag lists grow.
  • JpaRepository.count() translates to SELECT COUNT(*) FROM table — a single indexed scan. No explain-plan concern at any foreseeable table size.
  • The api.GET('/api/admin/stats') call in +layout.server.ts runs server-side (SSR), so the HTTP round-trip stays internal (SvelteKit Node process → Spring Boot). No public-network latency added.
  • CI: the backend test suite will need an AdminStatsControllerTest (or equivalent) — this runs in the existing @WebMvcTest slice pattern and adds minimal time. No new Testcontainers setup needed since count queries don't exercise anything Postgres-specific.

Recommendations

  • No infrastructure changes required. Ship the application change without touching docker-compose.yml, CI workflows, or deployment config.
  • If you add Actuator metrics (management.metrics.enabled=true is the default in Boot 4), the new endpoint will automatically appear in /actuator/metrics/http.server.requests — no extra instrumentation needed. That's a nice monitoring win for free.
  • After the refactor, verify the admin panel load time in the browser devtools network tab to confirm the three sequential/parallel fetch waterfall is replaced by one. Not a CI gate, just a sanity check worth noting in the PR description.

Open Decisions

  • None from a DevOps perspective — this is a clean in-process refactor.
## 👨‍💻 Tobias Wendt — DevOps & Platform Engineer ### Observations - This is a pure application-layer refactor: no new Docker services, no new infrastructure, no Flyway migrations. The operational footprint is unchanged. - The change reduces backend load on every admin page navigation — three full-list queries become one count query. At the current scale this is negligible, but the pattern (fetching lists to count them) is the kind of thing that would show up under load as an N+1 in disguise. Worth fixing before the user/group/tag lists grow. - `JpaRepository.count()` translates to `SELECT COUNT(*) FROM table` — a single indexed scan. No explain-plan concern at any foreseeable table size. - The `api.GET('/api/admin/stats')` call in `+layout.server.ts` runs server-side (SSR), so the HTTP round-trip stays internal (SvelteKit Node process → Spring Boot). No public-network latency added. - CI: the backend test suite will need an `AdminStatsControllerTest` (or equivalent) — this runs in the existing `@WebMvcTest` slice pattern and adds minimal time. No new Testcontainers setup needed since count queries don't exercise anything Postgres-specific. ### Recommendations - No infrastructure changes required. Ship the application change without touching `docker-compose.yml`, CI workflows, or deployment config. - If you add Actuator metrics (`management.metrics.enabled=true` is the default in Boot 4), the new endpoint will automatically appear in `/actuator/metrics/http.server.requests` — no extra instrumentation needed. That's a nice monitoring win for free. - After the refactor, verify the admin panel load time in the browser devtools network tab to confirm the three sequential/parallel fetch waterfall is replaced by one. Not a CI gate, just a sanity check worth noting in the PR description. ### Open Decisions - None from a DevOps perspective — this is a clean in-process refactor.
Author
Owner

👨‍💻 Elicit — Requirements Engineer

Observations

  • The issue is well-formed: it has a clear problem statement, explicit acceptance criteria, and a traceable origin (the TODO comment in +layout.server.ts:29 extracted during CLEANUP-2 #413). Definition-of-Ready score is high.
  • One gap in the acceptance criteria: the permission level on GET /api/admin/stats is unspecified. The issue says @RequirePermission(Permission.ADMIN) but the layout currently grants access to users with any of ADMIN | ADMIN_USER | ADMIN_TAG | ADMIN_PERMISSION. A user with only ADMIN_TAG (tag administrator) currently sees their tag count in the nav bar. If the new endpoint uses Permission.ADMIN, that user will get a 403 and see no counts — a regression in observable behavior even though the AC doesn't call it out.
  • Invite count is out of scope but adjacent: the layout also fetches invite count via raw fetch. That fetch is separate and not covered by this issue. Fine — but worth a note so it doesn't get accidentally touched during implementation.
  • "Backend test covers the stats endpoint response shape": the AC is correct but under-specified. The test should also verify 401 (unauthenticated) and 403 (authenticated, wrong permission) — not just the 200 response shape. Without 401/403 coverage the permission annotation could be removed accidentally.
  • The AC says "existing admin management pages continue to fetch full lists from their own routes, not the layout." This is already the case — the layout never passed the full arrays to sub-routes; it only passed .length. The AC is confirming no regression, not describing a new behavior. That's fine.

Recommendations

  • Clarify the permission level before implementation starts — add it explicitly to the AC: @RequirePermission(Permission.ADMIN_USER) or a broader check. Do not leave this for the implementer to decide; the permission level is a product decision, not an engineering one.
  • Expand the test AC to: "Backend test covers: 401 unauthenticated, 403 wrong permission, 200 correct { userCount, groupCount, tagCount } shape."
  • Add an AC for the frontend spec: "Frontend layout.server.spec.ts mock updated to reflect single-endpoint fetch; all existing test assertions continue to pass."

Open Decisions

  • Permission level: Which admin roles should be able to call /api/admin/stats? Options: (a) any of the four admin permissions — matches the current nav gate; (b) ADMIN_USER only — user admins see all counts but tag-only admins see no counts in nav; (c) new Permission.ADMIN_STATS — maximum precision but adds enum value. The product owner should decide this before implementation.
## 👨‍💻 Elicit — Requirements Engineer ### Observations - The issue is well-formed: it has a clear problem statement, explicit acceptance criteria, and a traceable origin (the TODO comment in `+layout.server.ts:29` extracted during CLEANUP-2 #413). Definition-of-Ready score is high. - **One gap in the acceptance criteria**: the permission level on `GET /api/admin/stats` is unspecified. The issue says `@RequirePermission(Permission.ADMIN)` but the layout currently grants access to users with any of `ADMIN | ADMIN_USER | ADMIN_TAG | ADMIN_PERMISSION`. A user with only `ADMIN_TAG` (tag administrator) currently sees their tag count in the nav bar. If the new endpoint uses `Permission.ADMIN`, that user will get a 403 and see no counts — a regression in observable behavior even though the AC doesn't call it out. - **Invite count is out of scope but adjacent**: the layout also fetches invite count via raw `fetch`. That fetch is separate and not covered by this issue. Fine — but worth a note so it doesn't get accidentally touched during implementation. - **"Backend test covers the stats endpoint response shape"**: the AC is correct but under-specified. The test should also verify 401 (unauthenticated) and 403 (authenticated, wrong permission) — not just the 200 response shape. Without 401/403 coverage the permission annotation could be removed accidentally. - The AC says "existing admin management pages continue to fetch full lists from their own routes, not the layout." This is already the case — the layout never passed the full arrays to sub-routes; it only passed `.length`. The AC is confirming no regression, not describing a new behavior. That's fine. ### Recommendations - Clarify the permission level before implementation starts — add it explicitly to the AC: `@RequirePermission(Permission.ADMIN_USER)` or a broader check. Do not leave this for the implementer to decide; the permission level is a product decision, not an engineering one. - Expand the test AC to: "Backend test covers: 401 unauthenticated, 403 wrong permission, 200 correct `{ userCount, groupCount, tagCount }` shape." - Add an AC for the frontend spec: "Frontend `layout.server.spec.ts` mock updated to reflect single-endpoint fetch; all existing test assertions continue to pass." ### Open Decisions - **Permission level**: Which admin roles should be able to call `/api/admin/stats`? Options: (a) any of the four admin permissions — matches the current nav gate; (b) `ADMIN_USER` only — user admins see all counts but tag-only admins see no counts in nav; (c) new `Permission.ADMIN_STATS` — maximum precision but adds enum value. The product owner should decide this before implementation.
Author
Owner

👨‍💻 Nora "NullX" Steiner — Security Engineer

Observations

  • Current state: The three full-list endpoints (/api/users, /api/groups, /api/tags) are each individually permission-gated: users and groups require ADMIN_USER/ADMIN_PERMISSION respectively; tags require no permission at all (GET /api/tags is unguarded — it's the tag search/autocomplete endpoint used everywhere). The layout is leaking a read of the full tag list to establish a count, bypassing the fact that tag count is not sensitive data.
  • New endpoint permission: The issue specifies @RequirePermission(Permission.ADMIN). Given that the full user list requires ADMIN_USER and the full group list requires ADMIN_PERMISSION, the new endpoint returning only counts is strictly less sensitive. Permission.ADMIN is a reasonable lower bound; the data (integer counts) carries no PII. However, the permission must not accidentally be left off — without @RequirePermission the endpoint would be accessible to any authenticated user with READ_ALL.
  • No IDOR risk: the endpoint returns aggregate counts, not individual entity data. No UUID in the response, no entity lookup by user-controlled parameter. Attack surface is minimal.
  • Existing AdminController has @RequirePermission(Permission.ADMIN) at class level. If the new stats method is added there, it inherits ADMIN — which is the maintenance permission. A user with ADMIN_USER but not ADMIN would be blocked. This is a silent security regression (user admins can currently see the count via the full list, but the new more-efficient endpoint would block them).
  • Test coverage: The issue AC asks for a test covering "response shape." Nora flags: the test must also cover the 401 and 403 paths. Permission annotation removal is a common refactor mistake; only the negative test catches it.

Recommendations

  • Do not add this endpoint to AdminController with its class-level @RequirePermission(Permission.ADMIN). Create a dedicated controller or use method-level annotation to set the correct permission for the stats endpoint independently.
  • The method-level @RequirePermission annotation must be present and tested: write should_return_403_when_user_lacks_any_admin_permission() and should_return_401_when_unauthenticated() tests before writing the implementation.
  • The response payload (integer counts) contains no PII, so no data-classification concern applies.
  • SLF4J parameterized logging is fine if a debug log is added (log.debug("Admin stats fetched: users={}, groups={}, tags={}", u, g, t)). Do not log entity names or IDs — not applicable here since the response contains only counts.

Open Decisions

  • None beyond the permission level question already raised by Elicit — this is a product decision that determines the security boundary.
## 👨‍💻 Nora "NullX" Steiner — Security Engineer ### Observations - **Current state**: The three full-list endpoints (`/api/users`, `/api/groups`, `/api/tags`) are each individually permission-gated: users and groups require `ADMIN_USER`/`ADMIN_PERMISSION` respectively; tags require no permission at all (`GET /api/tags` is unguarded — it's the tag search/autocomplete endpoint used everywhere). The layout is leaking a read of the full tag list to establish a count, bypassing the fact that tag count is not sensitive data. - **New endpoint permission**: The issue specifies `@RequirePermission(Permission.ADMIN)`. Given that the full user list requires `ADMIN_USER` and the full group list requires `ADMIN_PERMISSION`, the new endpoint returning only counts is strictly less sensitive. `Permission.ADMIN` is a reasonable lower bound; the data (integer counts) carries no PII. However, the permission must not accidentally be left off — without `@RequirePermission` the endpoint would be accessible to any authenticated user with `READ_ALL`. - **No IDOR risk**: the endpoint returns aggregate counts, not individual entity data. No UUID in the response, no entity lookup by user-controlled parameter. Attack surface is minimal. - **Existing `AdminController`** has `@RequirePermission(Permission.ADMIN)` at class level. If the new stats method is added there, it inherits `ADMIN` — which is the maintenance permission. A user with `ADMIN_USER` but not `ADMIN` would be blocked. This is a silent security regression (user admins can currently see the count via the full list, but the new more-efficient endpoint would block them). - **Test coverage**: The issue AC asks for a test covering "response shape." Nora flags: the test must also cover the 401 and 403 paths. Permission annotation removal is a common refactor mistake; only the negative test catches it. ### Recommendations - Do not add this endpoint to `AdminController` with its class-level `@RequirePermission(Permission.ADMIN)`. Create a dedicated controller or use method-level annotation to set the correct permission for the stats endpoint independently. - The method-level `@RequirePermission` annotation must be present and tested: write `should_return_403_when_user_lacks_any_admin_permission()` and `should_return_401_when_unauthenticated()` tests before writing the implementation. - The response payload (integer counts) contains no PII, so no data-classification concern applies. - SLF4J parameterized logging is fine if a debug log is added (`log.debug("Admin stats fetched: users={}, groups={}, tags={}", u, g, t)`). Do not log entity names or IDs — not applicable here since the response contains only counts. ### Open Decisions - None beyond the permission level question already raised by Elicit — this is a product decision that determines the security boundary.
Author
Owner

👨‍💻 Sara Holt — QA Engineer

Observations

  • The existing layout.server.spec.ts is well-structured: it uses vi.mock, factory functions for users, and covers permission edge cases (no perms, undefined user, no groups, tag-admin-only access). After the refactor, the mockApi helper and the returns entity counts test will need updating — the three-sequential-GET mock becomes a single GET.
  • The existing StatsControllerTest (for GET /api/stats) is a good reference pattern for the new admin stats controller test: it uses @WebMvcTest, imports SecurityConfig + PermissionAspect + AopAutoConfiguration, and tests 401, 403, and 200. The new test should follow the same pattern exactly.
  • The acceptance criteria says "backend test covers the stats endpoint response shape." This is necessary but not sufficient. The test pyramid for this endpoint should be:
    • Unit test (AdminStatsServiceTest): mock the three repositories, verify the DTO is constructed correctly with correct counts.
    • Controller slice test (AdminStatsControllerTest): @WebMvcTest, verify 401/403/200 with response shape assertion.
    • No integration test needed — JpaRepository.count() is framework code; there's nothing domain-specific to test at the integration layer.
  • The frontend spec update is straightforward: replace the three-mockResolvedValueOnce chain with a single mockResolvedValue returning { userCount: 2, groupCount: 1, tagCount: 3 }. All existing assertions (expect(result.userCount).toBe(2) etc.) continue to pass.
  • No E2E test needed for this change — it is an internal plumbing refactor with no user-visible behavior change (the nav bar counts look identical before and after).

Recommendations

  • Write the AdminStatsControllerTest covering: getStats_returns401_whenUnauthenticated, getStats_returns403_whenUserLacksAdminPermission, getStats_returns200_withCorrectShape. Mirror the pattern from StatsControllerTest.
  • Write AdminStatsServiceTest with mocked repositories: one test for the happy path, one for all-zero counts.
  • Update layout.server.spec.ts: the mockApi helper should switch from three mockResolvedValueOnce calls to one that returns { data: { userCount, groupCount, tagCount } }. The existing permission-gate tests (throws 403, etc.) don't invoke mockApi so they require no changes.
  • After npm run generate:api, run npm run check to confirm the new API type resolves correctly in +layout.server.ts before committing.

Open Decisions

  • None — the test strategy is clear and the AC is implementable once the permission level is decided.
## 👨‍💻 Sara Holt — QA Engineer ### Observations - The existing `layout.server.spec.ts` is well-structured: it uses `vi.mock`, factory functions for users, and covers permission edge cases (no perms, undefined user, no groups, tag-admin-only access). After the refactor, the `mockApi` helper and the `returns entity counts` test will need updating — the three-sequential-GET mock becomes a single GET. - The existing `StatsControllerTest` (for `GET /api/stats`) is a good reference pattern for the new admin stats controller test: it uses `@WebMvcTest`, imports `SecurityConfig + PermissionAspect + AopAutoConfiguration`, and tests 401, 403, and 200. The new test should follow the same pattern exactly. - The acceptance criteria says "backend test covers the stats endpoint response shape." This is necessary but not sufficient. The test pyramid for this endpoint should be: - **Unit test** (`AdminStatsServiceTest`): mock the three repositories, verify the DTO is constructed correctly with correct counts. - **Controller slice test** (`AdminStatsControllerTest`): `@WebMvcTest`, verify 401/403/200 with response shape assertion. - No integration test needed — `JpaRepository.count()` is framework code; there's nothing domain-specific to test at the integration layer. - The frontend spec update is straightforward: replace the three-`mockResolvedValueOnce` chain with a single `mockResolvedValue` returning `{ userCount: 2, groupCount: 1, tagCount: 3 }`. All existing assertions (`expect(result.userCount).toBe(2)` etc.) continue to pass. - No E2E test needed for this change — it is an internal plumbing refactor with no user-visible behavior change (the nav bar counts look identical before and after). ### Recommendations - Write the `AdminStatsControllerTest` covering: `getStats_returns401_whenUnauthenticated`, `getStats_returns403_whenUserLacksAdminPermission`, `getStats_returns200_withCorrectShape`. Mirror the pattern from `StatsControllerTest`. - Write `AdminStatsServiceTest` with mocked repositories: one test for the happy path, one for all-zero counts. - Update `layout.server.spec.ts`: the `mockApi` helper should switch from three `mockResolvedValueOnce` calls to one that returns `{ data: { userCount, groupCount, tagCount } }`. The existing permission-gate tests (throws 403, etc.) don't invoke `mockApi` so they require no changes. - After `npm run generate:api`, run `npm run check` to confirm the new API type resolves correctly in `+layout.server.ts` before committing. ### Open Decisions - None — the test strategy is clear and the AC is implementable once the permission level is decided.
Author
Owner

👨‍💻 Leonie Voss — UI/UX Designer

Observations

  • This change is invisible to end users — the admin nav bar counts (2 users, 1 group, 5 tags) continue to render identically. No visual regression, no layout change, no Svelte component changes.
  • EntityNav.svelte receives userCount, groupCount, tagCount as individual number props — this prop interface is unchanged by the refactor. The counts are rendered as badge text inside EntityNavSection components. No touch-target, contrast, or font-size implications.
  • The flyout panel on tablet (md breakpoint) also passes the same props. No responsive behavior changes.
  • The admin area is currently hidden on mobile (hidden md:flex). This is a pre-existing design decision outside this issue's scope.

Recommendations

  • No UI changes needed. After implementation, do a quick visual check of the admin nav on desktop and tablet to confirm the count badges still appear with the correct values — this is a 30-second sanity check, not a test to write.
  • If the counts briefly show 0 or missing during a future SSR error state (e.g., if the new stats endpoint is slow or unavailable), the nav badges should degrade gracefully. The current error handling in +layout.server.ts throws a SvelteKit error() which results in a full error page — that's fine and already matches existing behavior.

Open Decisions

  • None from a UI/UX perspective.
## 👨‍💻 Leonie Voss — UI/UX Designer ### Observations - This change is invisible to end users — the admin nav bar counts (`2 users`, `1 group`, `5 tags`) continue to render identically. No visual regression, no layout change, no Svelte component changes. - `EntityNav.svelte` receives `userCount`, `groupCount`, `tagCount` as individual number props — this prop interface is unchanged by the refactor. The counts are rendered as badge text inside `EntityNavSection` components. No touch-target, contrast, or font-size implications. - The flyout panel on tablet (md breakpoint) also passes the same props. No responsive behavior changes. - The admin area is currently hidden on mobile (`hidden md:flex`). This is a pre-existing design decision outside this issue's scope. ### Recommendations - No UI changes needed. After implementation, do a quick visual check of the admin nav on desktop and tablet to confirm the count badges still appear with the correct values — this is a 30-second sanity check, not a test to write. - If the counts briefly show `0` or missing during a future SSR error state (e.g., if the new stats endpoint is slow or unavailable), the nav badges should degrade gracefully. The current error handling in `+layout.server.ts` throws a SvelteKit `error()` which results in a full error page — that's fine and already matches existing behavior. ### Open Decisions - None from a UI/UX perspective.
Author
Owner

Decision Queue

Raised by Markus, Elicit, and Nora. One theme dominates:


Theme 1: Permission level on GET /api/admin/stats

The issue body specifies @RequirePermission(Permission.ADMIN) but three personas independently flagged this as a regression: users with ADMIN_TAG or ADMIN_USER (without ADMIN) currently see entity counts in the nav bar — they reach those counts via the full-list endpoints they already have access to. A blanket ADMIN guard on the new endpoint would silently break the count display for those users.

Options:

Option Permission Effect
A Permission.ADMIN Matches the issue's current spec. Only maintenance admins (ADMIN) can call the endpoint. Tag-only and user-only admins see no counts — silent regression.
B Permission.ADMIN_USER User admins see all counts. Tag-only admins (ADMIN_TAG only) still blocked. Partial regression for tag-only admins.
C Any admin permission (composite check) Matches the current layout gate. Any of ADMIN, ADMIN_USER, ADMIN_TAG, ADMIN_PERMISSION can call the endpoint. No regression. Requires either a new Permission value or a composite @RequirePermission approach.
D New Permission.ADMIN_STATS Maximum precision. Adds one enum value and requires mirror in frontend/src/lib/shared/errors.ts + i18n keys. Most future-proof but most churn for a simple count endpoint.

Recommended default (implement unless overridden): Option C — any admin permission grants access to counts. The data is non-sensitive (integers, no PII), and it matches the existing behavior exactly. Implement as: do not use the existing AdminController (which has ADMIN at class level); instead create a dedicated AdminStatsController with no class-level @RequirePermission, and apply the permission check at method level using whatever is the least-privileged admin permission the endpoint should accept.


Theme 2: Which controller to put the new endpoint in

Markus and Nora both flagged that adding GET /api/admin/stats to the existing AdminController would inherit its class-level @RequirePermission(Permission.ADMIN), causing the permission regression above. The unanimous recommendation: create a new AdminStatsController in the user package. This avoids touching AdminController's permission model, keeps SRP, and is a trivial addition.

## Decision Queue Raised by Markus, Elicit, and Nora. One theme dominates: --- ### Theme 1: Permission level on `GET /api/admin/stats` The issue body specifies `@RequirePermission(Permission.ADMIN)` but three personas independently flagged this as a regression: users with `ADMIN_TAG` or `ADMIN_USER` (without `ADMIN`) currently see entity counts in the nav bar — they reach those counts via the full-list endpoints they already have access to. A blanket `ADMIN` guard on the new endpoint would silently break the count display for those users. **Options:** | Option | Permission | Effect | |---|---|---| | A | `Permission.ADMIN` | Matches the issue's current spec. Only maintenance admins (`ADMIN`) can call the endpoint. Tag-only and user-only admins see no counts — silent regression. | | B | `Permission.ADMIN_USER` | User admins see all counts. Tag-only admins (`ADMIN_TAG` only) still blocked. Partial regression for tag-only admins. | | C | Any admin permission (composite check) | Matches the current layout gate. Any of `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION` can call the endpoint. No regression. Requires either a new `Permission` value or a composite `@RequirePermission` approach. | | D | New `Permission.ADMIN_STATS` | Maximum precision. Adds one enum value and requires mirror in `frontend/src/lib/shared/errors.ts` + i18n keys. Most future-proof but most churn for a simple count endpoint. | **Recommended default (implement unless overridden):** Option C — any admin permission grants access to counts. The data is non-sensitive (integers, no PII), and it matches the existing behavior exactly. Implement as: do not use the existing `AdminController` (which has `ADMIN` at class level); instead create a dedicated `AdminStatsController` with no class-level `@RequirePermission`, and apply the permission check at method level using whatever is the least-privileged admin permission the endpoint should accept. --- ### Theme 2: Which controller to put the new endpoint in Markus and Nora both flagged that adding `GET /api/admin/stats` to the existing `AdminController` would inherit its class-level `@RequirePermission(Permission.ADMIN)`, causing the permission regression above. The unanimous recommendation: **create a new `AdminStatsController`** in the `user` package. This avoids touching `AdminController`'s permission model, keeps SRP, and is a trivial addition.
Sign in to join this conversation.
No Label P3-later refactor
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#453