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 |
|---|---|---|
|
|
|
|
|
|
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:
-
Tenant from the header, never from the request body. The active tenant comes from
@TenantId(header-only). The tenant endpoint does not declaretenantIdas 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. -
Server-side composition with overwrite.
CommandActivityReadServicebuilds a freshCommandLogQueryParamsand writes the header tenant onto it explicitly. If atenantIdever rides along through deserialisation, it is overwritten — never honoured. -
Smuggle attempts get a 400, not silently ignored. The whitelist is enforced via
BeanIntrospectionoverCommandActivityQuery + Paging. Any query parameter not declared on those beans is rejected withFinstaProblemCode.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. -
?orderByField=is enum-restricted. Allowed values are constrained toCommandLogOrderField— 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 |
Backend invariants
-
Importance floor is pinned at
Normalserver-side.Low-importance rows never reach a regular user. -
cmdBodyandcmdProblemare 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. -
cmdBodyredaction is opt-in viano.conta.command.Redactable. Commands that carry file uploads (AddFileCommand,AddAttachmentCommand) implementRedactable, returning a copy with the byte payload cleared. The size lives onFinstaFile.size(a separateLongfield), so the curated chip can show "4.2 KB" even after redaction has stripped the bytes. -
cmdUuidis globally unique across all tenants. The frontend dedupes its accumulated row list bycmdUuid. -
Forked commands (
cmdSourceRefnon-null) are first-class — they appear in the log too. They typically carry the parent’suserIdand 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 byFinancialStatementServiceBean.synchronizeafter 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 inrouter/paths.tsasactivityMonitorRoutewithreleaseStage: 'alpha'so the menu entry stays admin-only viauseAuthStore().hasAdminPrivilegesand ops has a no-redeploy kill-switch via menu visibility. -
Pinia store:
useCommandActivityStore(idcommandActivity). NotenantIdfield — the active tenant is sent automatically viaConta-Tenant-Idby the existing HTTP-client interceptor.
Pagination and loading
-
Pagination is
since/beforecursor atPAGE_SIZE = 50, not page-number pagination. The cursor advances bystartedAt; under tied milliseconds or multi-pod clock skew the page seam can produce inversions, which is whyfilteredRowsre-sorts newest-first — the grouper’sgap >= 0invariant 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. -
loadAllis hard-capped atLOAD_ALL_CAP = 5000rows 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()emptiesaccumulated, resetsnoMoreOlder, 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 whenevertotalLoaded > 0and 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.
Grouping and display
-
Grouping rule (
useCommandActivityGrouping): consecutive rows with the same(cmdClass, userId, primaryAttributeUuid)and an idle gap of⇐ IDLE_GAP_MSbetween adjacent entries form one group.primaryAttributeUuidis the first-present offsUuid → assregUuid → finregUuid → cogregUuid. If none are present, the key is(cmdClass, userId). -
Month dividers:
decoratedRowswalks the (newest-first) groups and injects aMonthDividerentry whenever the first row of a group falls in a differentYYYY-MMthan 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 ofuserIdmodulo palette size). No display names are exposed. -
FS identity per row: a 4-char colored mono chip (
CommandActivityFsChip.vue).chipUuidresolves the row’s primary scope uuid to the parent FS uuid viaallFslookup 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
isolatewrapper 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 |
|---|---|
|
|
|
Date + amount + counterpart account for create/update/delete-transaction commands. |
|
Filename + human-readable size (e.g. |
|
|
|
Signer-name chip + contact-email-count chip + one chip per active |
|
Identity chip ( |
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.stopand calluseCommandActivityStore().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 callsremoveSoftto undo it.
Rollout
-
The backend authority
GA_FINSTA_COMMAND_VIEW_TENANTis granted to all tenantUSER_*grants (ALL_USER_GRANTS) from day one — every user technically has API access. -
The frontend hides the sidebar entry behind
releaseStage: 'alpha', which routes throughuseAuthStore().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 |
Add a sensitive field to a command body |
Implement |
Tweak the grouping window |
Constant |
Adjust the colour palette |
|
Tweak the FS multiselect default |
|
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 |
Add a new orderable field |
Extend the |
Cap the loaded-row budget |
|