Command log activity (tenant-facing)

The user-facing activity log surface. See Command tracing for the underlying tracing mechanism.

Why this exists

Every write command in Finsta is recorded in command_log. Until v2, the only way to see this data was the technical support UI under views/support/command-log/, gated by GA_FINSTA_COMMAND_VIEW_GLOBAL and intended for support / dev / admin roles.

Regular tenant users had no way to answer questions like "who edited the trial balance yesterday?" or "why didn’t my submission go through?".

The activity monitor fills that gap. It is a chronological, friendly read of the same command_log data, scoped strictly to the active tenant and tuned for non-technical users.

Two controllers, one path family

The original CommandLogController was split along the global-vs-tenant axis.

Path Controller Notes

GET /command-logs/global
GET /command-logs/global/{cmdUuid}

GlobalCommandLogController

@Secured(GA_FINSTA_COMMAND_VIEW_GLOBAL), internal-only. The wide-search endpoint accepts ?tenantId=… (cross-tenant search by design). The single-cmd lookup is header-only (Conta-Tenant-Id) — no query-param fallback, since "support digs into a known tenant’s specific command" is always a deliberate, header-stamped action.

GET /command-logs/tenant

CommandLogController (tenant-facing)

@Secured(GA_FINSTA_COMMAND_VIEW_TENANT). GA_FINSTA_COMMAND_VIEW_TENANT is in ALL_USER_GRANTS — every authenticated tenant role can read their own activity. Tenant comes from the canonical header chain (Conta-Tenant-IdtenantIdorganizationId) via the @TenantId argument binder. Cross-tenant queries are not expressible.

The split is at the controller / authority / composition layer. The data layer (CommandActivityReadService, CommandLogQueryParams, FinstaCommandLogRepository, CommandLog DTO) is shared. Each controller composes its own params object; both funnel through the same query method.

@TenantId argument binder

The migration replaced direct TrittTenantResolver injection and the legacy TenantIdParams (which had a query-param fallback) with a single annotation:

public Slice<CommandLog> findCommandLogs(@TenantId String tenantId, ...) { ... }

The binder reads only from request headers via TrittTenantResolver. Query-param fallback was a horizontal-privilege-escalation hazard if conta-security ever drifted, so it is now gone. Missing tenant header surfaces as 400 Bad Request rather than 500.

@TenantId is stamped with @Parameter(hidden=true) so the OpenAPI plugin doesn’t infer the unannotated String parameter as a body field — without it, the TS codegen fails at vite parse on synchronizeOrganization (an allOf intersection sneaks in).

Hardening the tenant endpoint

Three properties combined make it impossible for a regular user to express a cross-tenant query:

  1. Tenant from the header, never from the request body. The active tenant comes from @TenantId (header-only). The tenant endpoint does not declare tenantId as an accepted parameter. The security pipeline already validates that the authenticated user has access to the tenant identified in the header before the handler runs.

  2. Server-side composition with overwrite. CommandActivityReadService builds a fresh CommandLogQueryParams and writes the header tenant onto it explicitly. If a tenantId ever rides along through deserialisation, it is overwritten — never honoured.

  3. Smuggle attempts get a 400, not silently ignored. The whitelist is enforced via BeanIntrospection over CommandActivityQuery + Paging. Any query parameter not declared on those beans is rejected with FinstaProblemCode.UnsupportedQueryParameter (1105) plus a helpful message. A caller trying to influence the importance floor, command-type filter, attribute filter, or sort order gets told they were spotted.

  4. ?orderByField= is enum-restricted. Allowed values are constrained to CommandLogOrderField — which deliberately excludes the JSONB columns where ordering would pin a worker on the unindexed global table. Anything else: 400 + UnsupportedQueryParameter.

Schema model differs from other tenant endpoints. Most Finsta tenant endpoints target a tenant-specific PostgreSQL schema — the search_path is set to the tenant’s schema and queries are implicitly scoped. command_log is different: it lives in the global schema with tenant_id as a discriminator column. Tenant scoping is not implicit — every query must explicitly include WHERE tenant_id = :activeTenant.

Backend invariants

  • Importance floor is pinned at Normal server-side. Low-importance rows never reach a regular user.

  • cmdBody and cmdProblem are always included in the response (CommandFetchOption.IncludeCmdBody + IncludeProblem). The frontend uses the body to extract curated fields; the problem makes failure rendering possible. The body itself is never displayed raw to the user — only specific fields go through per-cmd-class curation components.

  • cmdBody redaction is opt-in via no.conta.command.Redactable. Commands that carry file uploads (AddFileCommand, AddAttachmentCommand) implement Redactable, returning a copy with the byte payload cleared. The size lives on FinstaFile.size (a separate Long field), so the curated chip can show "4.2 KB" even after redaction has stripped the bytes.

  • cmdUuid is globally unique across all tenants. The frontend dedupes its accumulated row list by cmdUuid.

  • Forked commands (cmdSourceRef non-null) are first-class — they appear in the log too. They typically carry the parent’s userId and so render under the same colour stripe as the user action that triggered them.

  • Audit-log info entries with row counts are emitted alongside `MdcServerFilter’s per-request entry log, so support can correlate "what did this user just look at" without enabling debug logging.

Tracing attributes (fsUuid)

Commands that scope to an FS opt into FsUuidAttributeEnabled so the tracing pipeline captures fsUuid as an attribute on the command log. The interface is now nullable — a single contract covers both:

  • Caller-supplied attributes (request-time, e.g. EnsureBoardCommand.fsUuid).

  • Internally-stamped attributes (result-time, e.g. CreateFinancialStatementCommand.fsUuid — stamped by FinancialStatementServiceBean.synchronize after entity creation).

The contract returns Info.ofNonNull so a null fsUuid is omitted from the attribute map rather than written as a null entry. @InternalField forbids external callers from supplying it and hides it from OpenAPI; @Nullable on the read side reminds callers it may be missing.

The frontend’s chipUuid resolution closes the loop: rows scoped only to a registry uuid (e.g. assreg from a CreateFixedAssetCommand) are mapped back to the parent FS for display so chip identity is consistent across all rows of the same FS.

Route and store

  • Route: /organization/:tenantId/financial-statements/:uuid/activity-monitor, registered in router/paths.ts as activityMonitorRoute with releaseStage: 'alpha' so the menu entry stays admin-only via useAuthStore().hasAdminPrivileges and ops has a no-redeploy kill-switch via menu visibility.

  • Pinia store: useCommandActivityStore (id commandActivity). No tenantId field — the active tenant is sent automatically via Conta-Tenant-Id by the existing HTTP-client interceptor.

Pagination and loading

  • Pagination is since/before cursor at PAGE_SIZE = 50, not page-number pagination. The cursor advances by startedAt; under tied milliseconds or multi-pod clock skew the page seam can produce inversions, which is why filteredRows re-sorts newest-first — the grouper’s gap >= 0 invariant then holds by construction.

  • withMinDelay (800 ms floor) is reserved for user-clicked single fetches (loadMore, loadNewer) so the spinner stays visible long enough to register. Loops (loadAll, loadUntilFsCreatedAt) bypass the drag.

  • loadAll is hard-capped at LOAD_ALL_CAP = 5000 rows so a runaway tenant can’t OOM the browser or freeze the render. When this becomes a real ceiling in practice, the next move is virtualised scrolling or a server-side search UI.

  • clearLoaded() empties accumulated, resets noMoreOlder, and immediately reloads the first page so the user lands on a fresh, in-budget view without having to click load again. Wired to a "Tøm" / "Clear" button in the sticky bottom bar; visible whenever totalLoaded > 0 and the loop isn’t running.

Filtering pipeline

The client-side pipeline is: loaded rows → fs-multiselect filter → importance filter → dry-run filter → grouping → render. Filters apply before grouping, so groups always show 100% matching members and counts are honest.

FS multiselect

State shape: { followCurrent: boolean, explicit: Set<fsUuid>, soft?: Set<fsUuid> }.

explicit = hard-selected by the user. soft = implicitly added as a companion (e.g. source and created FS are paired when clicking a "Continued" chip — source goes to explicit, created goes to soft). Clicking a soft entry in the picker promotes it to explicit; clicking an explicit entry removes it.

effectiveFsUuids = explicit ∪ soft ∪ (followCurrent ? currentFs : ∅).

The "Dette årsoppgjøret" pseudo-entry in the picker maps to followCurrent. Default is { followCurrent: true, explicit: ∅ } — only the current FS.

URL reflection mirrors selection into ?followCurrent=false&fs=uuid,uuid so refresh / shared links restore state (router.replace, no history pollution; defaults omitted).

effectiveAttrUuids expands each selected FS to also include its registry uuids (assregUuid, finregUuid, cogregUuid), so registry-scoped rows pass through automatically.

Org-wide rows (no scope attributes) are always shown. Rows belonging to FSes outside the effective set are fully hidden.

"Last fram til årsoppgjøret ble opprettet" is shown only when the effective set has exactly one FS.

Importance filter

onlyHighImportance (store state, default false) — when on, only importance === 'High' rows are kept. High-importance rows also display a flag icon (i-ph:flag-fill) in the dedicated importance column.

Grouping and display

  • Grouping rule (useCommandActivityGrouping): consecutive rows with the same (cmdClass, userId, primaryAttributeUuid) and an idle gap of ⇐ IDLE_GAP_MS between adjacent entries form one group. primaryAttributeUuid is the first-present of fsUuid → assregUuid → finregUuid → cogregUuid. If none are present, the key is (cmdClass, userId).

  • Month dividers: decoratedRows walks the (newest-first) groups and injects a MonthDivider entry whenever the first row of a group falls in a different YYYY-MM than the previous group. No leading divider before the very first month.

  • "Deg vs Andre" treatment: a vertical colour bar per row in the "Bruker" column. The current user gets a fixed brand-accent colour; other users get a deterministic colour from IDENTIFIER_PALETTE (hash of userId modulo palette size). No display names are exposed.

  • FS identity per row: a 4-char colored mono chip (CommandActivityFsChip.vue). chipUuid resolves the row’s primary scope uuid to the parent FS uuid via allFs lookup so registry-scoped rows share chip identity with the parent FS. Dry-run rows render the chip as a diagonal-hatch repeating-gradient.

  • Counts: {matching} av {totalLoaded}+ while !noMoreOlder, {matching} av {totalLoaded} when all rows are loaded.

  • Stacking context: all row z-indices are contained inside an isolate wrapper so group-count chips (z-30) and connector lines (z-20) cannot escape above the sticky header or page menu.

Curated cmd info

Per-cmd-class *Info.vue components under views/command-activity/curated/, dispatched from CommandActivityRow.vue by cmdClass. Each component reads the (redacted) cmdBody and renders a small set of bordered chips that summarise the action at a glance.

Component What it shows

AddInputPostsInfo

<postNo>:<value> for a single entry, or {n} poster / entries for many.

TransactionInfo

Date + amount + counterpart account for create/update/delete-transaction commands.

AddFileInfo

Filename + human-readable size (e.g. tb-2019.csv · 4.2 KB). Reads fsFile.filename and fsFile.size from the redacted body. Used for both AddFileCommand and AddAttachmentCommand.

EnsureBoardInfo

Place · localised signing date chip + member-count chip; or a single Tømt/Cleared chip when the board report is being cleared (boardReport: null).

EnsureAnnualStatementDetailsInfo

Signer-name chip + contact-email-count chip + one chip per active Yes flag (preparedByAuthorizedAccountant, parentCompany, etc.). shouldNotBeRevised is excluded — it’s a derived display, not user input.

CreateFinancialStatementInfo

Identity chip (i2025 · name) + a split-button-style "Continued" chip when forkFrom is set (left half = label, right half = colored 4-char source-FS uuid; click adds source FS to explicit and created FS to soft in the filter) + a muted "Liquidation" tag when attributes.liquidation is set.

Convention

  • Atom per chip — split rather than concatenate when values describe distinct facts. Single-chip is fine when fields describe one artefact (e.g. filename + size of one file).

  • Reuse existing i18n keys before inventing new ones (continued, cmp.fs-attributes.Liquidation, etc.).

  • Filter-driving chips attach @click.stop and call useCommandActivityStore().addExplicit / addSoft. The FS chip in the picker bar is sufficient visual feedback — don’t add a separate "selected" ring to the curated chip.

  • Soft companions — when clicking a chip should also implicitly include a related FS (e.g. both sides of a fork lineage), add the companion with addSoft. Clicking the chip again calls removeSoft to undo it.

Rollout

  • The backend authority GA_FINSTA_COMMAND_VIEW_TENANT is granted to all tenant USER_* grants (ALL_USER_GRANTS) from day one — every user technically has API access.

  • The frontend hides the sidebar entry behind releaseStage: 'alpha', which routes through useAuthStore().hasAdminPrivileges. Admin / Developer roles can see it; ops has a no-redeploy kill-switch via menu visibility.

  • Once UX is validated, GA = drop the releaseStage: 'alpha' (or move to 'beta' first). Backend stays unchanged.

Where to add things

Goal Where

Show curated detail for a new command class

New *Info.vue under finsta-frontend/src/views/command-activity/curated/, dispatched from CommandActivityRow.vue by cmdClass. Follow the chip-per-atom convention; reuse existing i18n keys where possible.

Add a sensitive field to a command body

Implement no.conta.command.Redactable on the command class so cmdBody is redacted before storage. The default BeanCopyMethods.copy(this, "fieldName") covers the common case.

Tweak the grouping window

Constant IDLE_GAP_MS in useCommandActivityGrouping.ts.

Adjust the colour palette

colors.tsCURRENT_FS_COLOR for the current user / current FS, IDENTIFIER_PALETTE for everyone else. The exported chipColorForUuid helper is shared across user bar, FS chip, and the picker.

Tweak the FS multiselect default

initialFsSelection() in fsSelection.ts. Update fsSelection.spec.ts accordingly.

Add a new server-side filter

First ask whether it really belongs server-side — the activity log is deliberately client-side for everything except tenant + importance.

Allow a new query parameter

Add it as a field on CommandActivityQuery (or Paging); the BeanIntrospection-based whitelist picks it up automatically. Smuggle attempts on anything else still get a 400.

Add a new orderable field

Extend the CommandLogOrderField enum. The enum is the whitelist — any value the enum doesn’t define is rejected with FinstaProblemCode.UnsupportedQueryParameter.

Cap the loaded-row budget

LOAD_ALL_CAP in useCommandActivityStore.ts. The "Tøm" button (clearLoaded) lets users free memory without a full page reload.