Skip to content

Activity Log

Overview

The Activity Log is a read-only audit feed in the backoffice settings area. It shows a chronological list of recorded merchant actions — what was changed, on which record, by whom, and when — with a filter bar and a free-text search. It exists for auditing and troubleshooting.

Key Purpose: Show a filterable, chronological audit trail of backoffice actions.

Purpose

This page lets a merchant browse recorded actions in reverse-chronological order (newest first), narrow them by action, entity type, user, source and date range, search them free-text, and load older entries with a "Load More" button. Access is gated by the view-activity-logs permission.

Key Concepts

  • Log Entry: A stored record capturing the action performed, the actor (user ID and stored name), the affected record's name, class and id, the capacity the actor acted in (on_behalf), and an optional snapshot of the record's state at the time.
  • Actor: The user who performed the action, stored as an actor ID and a name denormalised at write time. The list renders "You" only when the viewer is the actor — the entry's actor is compared to the signed-in user's id. Any other row shows the stored actor_name, falling back to the looked-up user's username and then to "Unknown". (Verified: upvendo-backend app/RawModels/ActivityLog.php lines 95-102.)
  • On-Behalf Attribution: When a platform admin or a reseller acts inside a merchant's account, the actor stays the real acting user and on_behalf records the capacityinternal (Upvendo Support) or reseller. See On-Behalf Access.
  • Loggable Entity: The record the action applied to, stored as loggable_name (display name), loggable_type (fully-qualified class name) and loggable_id. The entity label shown in the UI is the last \-delimited segment of loggable_type (e.g. Device, DeviceProfile), or the translated "Unknown" when the type is empty.
  • Snapshot: An optional snapshot array capturing record state (or the fields that changed) at the time of the action. It is stored but is not rendered in the list UI.
  • Filter Options: The datatable response carries a filter_options block listing the actions, entities and actors actually present in this tenant's trail, so the filter bar only offers values that can return something. It is cached for 5 minutes.
  • Data Table Pagination: Entries are served by ActivityLogController::dataTableActivityLogService::dataTableActivityLogRepository::dataTable. (The controller injects ActivityLogService directly — there is no ActivityLogOrchestrator layer.)
  • Soft Delete & Archival: The activity_logs collection uses soft deletes and is archived; the ArchiveOldRecords command archives non-deleted ActivityLog records older than 3 years, split by year.

Actions

Browse Activity Log

View a reverse-chronological list of logged actions. Each row shows an entity-type chip, the record name, the action, the actor's name and the timestamp — plus a warning chip when the action was taken on the merchant's behalf.

Filter the Log

Narrow the list with the filter bar above it:

  • Action — multi-select, from the actions present in this tenant's trail
  • Type (entity) — multi-select, e.g. Item, Device, User, Role
  • User (actor) — searchable multi-select, populated from filter_options
  • Source — All / Upvendo Support / Reseller (the merchant's own staff cannot be selected; see Business Rules)
  • Date from / Date to — inclusive whole days

A Clear filters button appears once any filter is set. Changing any filter reloads the list from page 1.

Search the Log

The search box above the list matches free text against the record name, the actor name and the action value. It is debounced by 400ms and resets the list to page 1.

Load More

Fetch the next page of older entries. The UI requests one page at a time (component default take = 20) and appends results until all entries are loaded.

Location

  • Backoffice Route: /settings/activity-log (route name settings-activity-log)
  • Vue Component: src/views/settings/ActivityLogComponent.vue
  • Store Module: src/store/modules/activityLog.ts
  • Backend Controller: app/Http/Controllers/Api/ActivityLogController.php
  • API Endpoint: GET /back-office/settings/activity-logs/datatable
  • Permission: view-activity-logs, declared in Permissions::systemAdministration() as "View Activity Logs" (app/Constants/Permissions.php lines 389 and 935-939). The API route carries permission:view-activity-logs middleware (routes/api/backoffice/settings/activity-logs.php lines 8-9).
  • Navigation: Settings menu item "Activity Log" (icon tabler-file-report), which declares action: 'view', subject: 'activity-logs' and the literal permission string view-activity-logs. The literal string is needed because CASL rules are manage all for every back-office user, so action/subject alone would show the item to everyone. (Verified: upvendo-backoffice src/navigation/settings/index.ts lines 89-99.)

Stored Fields

These are the fields stored on each ActivityLog record (app/RawModels/ActivityLog.php).

Action

PropertyValue
Field IDaction
TypeString (enum value from ActivityLogActions)

Description: The type of action performed. See Action Types.


Actor

PropertyValue
Field IDactor
TypeString (user ID, or system)

Description: ID of the user who performed the action. Writes that are allowed to proceed without a request user store system (device profile transitions only).


Actor Name

PropertyValue
Field IDactor_name
TypeString

Description: Display name of the acting user, denormalised at write time (System for a system-actor write). It is replaced with "You" only when the viewer is that same user.


Loggable Name

PropertyValue
Field IDloggable_name
TypeString

Description: Display name of the affected record (e.g. the device, item or user name).


Loggable Type

PropertyValue
Field IDloggable_type
TypeString (fully-qualified class name)

Description: The model class of the affected record. Always written — logActivity() takes it as a required parameter. The entity label shown in the UI is the final \-delimited segment; a blank type renders as the translated "Unknown".


Loggable ID

PropertyValue
Field IDloggable_id
TypeString, nullable

Description: ID of the affected record. It is also the per-request de-duplication key (see Business Rules). Stored on write but not hydrated back onto the model by ActivityLogFactory, so it does not appear in the API payload. (Verified: upvendo-backend app/Traits/LogsActivity.php line 70; app/RawFactories/ActivityLogFactory.php lines 12-24.)


On Behalf

PropertyValue
Field IDon_behalf
TypeString, nullable — internal or reseller

Description: The capacity the actor acted in. null for the merchant's own staff; internal when a platform admin acted on this merchant; reseller when a reseller did. The actor ID and name stay the real acting user in every case. Rows written before the field existed read as null — there was no migration.


Snapshot

PropertyValue
Field IDsnapshot
TypeObject / array, nullable

Description: Optional state snapshot at the time of the action. Stored but not rendered in the list UI. Contents vary by writer — a full record snapshot for items and devices, id + device_profile_id + previous_device_profile_id for a profile transition, previous/new id lists for role and location assignment, { "changed": [...] } for an integration update. Sensitive values are deliberately excluded; see Business Rules.


Timestamps

PropertyValue
Field IDscreated_at, updated_at, deleted_at
TypeDateTime (deleted_at nullable)

Description: created_at is the time of the action and is what the UI shows as the row timestamp. deleted_at supports soft deletion.

The activity log does not store an IP address, a location ID, or a field-level old/new change diff. What changed is only visible to the extent a writer chose to put it in snapshot.

Action Types

The ActivityLogActions enum (app/Enums/ActivityLogActions.php) defines exactly these 22 values (count asserted in tests/Unit/Enums/ActivityLogActionsTest.php):

Enum caseStored value
Createdcreated
Updatedmodified
Deleteddeleted
Restoredrestored
ForceDeletedforce_deleted
TerminalActivatedterminal_activated
TerminalDeactivatedterminal_deactivated
ProfileAssignedprofile_assigned
ProfileChangedprofile_changed
ProfileRemovedprofile_removed
RolesAssignedroles_assigned
LocationsAssignedlocations_assigned
Invitedinvited
Connectedconnected
Disconnecteddisconnected
SetAsDefaultset_as_default
Exportedexported
AccessedOnBehalfaccessed_on_behalf
PasskeyAddedpasskey_added
PasskeyRemovedpasskey_removed
TrustedDeviceRemovedtrusted_device_removed
PinResetpin_reset

Note that the "updated" action is stored with the value modified, not updated.

What Gets Logged

Coverage spans roughly seven domains. Menu, catalogue and location records are logged automatically through repository lifecycle hooks (LogsRepositoryActivity wires create / update / delete / restore / force-delete); the rest are written explicitly by their service.

DomainRecordsActionsSource
Menu & catalogueCategory, Modifier, Modifier Group, Variant Group, Display Group, Menu, Offer, Reward, Gift Card Template, Custom Tax Ratecreated, modified, deleted, restored, force_deletedrepository hooks via app/Traits/LogsRepositoryActivity.php
ItemsItemcreated, modified, deleted, restored, force_deleted (with a full snapshot)app/Repositories/ItemRepository.php
LocationsLocationcreated, modified, deleted, restored, force_deletedapp/Repositories/LocationRepository.php (hooks)
DevicesDevicecreated, modified, deleted, terminal_activated, terminal_deactivatedapp/Services/BackOffice/DeviceService.php
Device profilesDeviceProfile; device→profile transitionscreated, modified, deleted; profile_assigned, profile_changed, profile_removedapp/Services/BackOffice/DeviceProfileService.php, app/Repositories/DeviceRepository.php
Team & accessUser, Roleinvited, deleted, roles_assigned, locations_assigned; role created / modified / deletedapp/Services/BackOffice/Settings/TeamService.php
Account securityUserpasskey_added, passkey_removed, trusted_device_removedapp/Services/PasskeyService.php, app/Services/AuthService.php
Payment & billing profilesPaymentProfile, BillingProfilecreated, modified, deleted, set_as_defaultapp/Services/BackOffice/Settings/PaymentProfileService.php, .../BillingProfileService.php
Third-party integrationsThirdPartyIntegrationconnected, disconnected, modified (recording which fields changed)app/Services/BackOffice/AbstractThirdPartyIntegrationService.php
POS configurationPosStaffCredential; PosRegister, FdmDevicecreated, modified, pin_reset, deleted; register/FDM device lifecycleapp/Services/BackOffice/Pos/PosStaffCredentialAdminService.php; PosRegisterRepository, FdmDeviceRepository (hooks)
CustomersCustomer exportexported (with row_count and the export filter)app/Services/BackOffice/CustomerService.php
On-behalf accessMerchantaccessed_on_behalfapp/Http/Middleware/AdminVendorOverride.php

Every writer stores loggable_name, loggable_type, loggable_id, actor, actor_name, action and on_behalf. snapshot is optional, and the item, device and device-profile writers all supply one.

Deliberate exclusions — these leave no row, by design:

  • Login, failed login and password change. AuthService's only audit write is trusted_device_removed; sign-in and updatePassword() write nothing.
  • Inbound POS / integration sync writes. logActivity() returns early when request()->user() is null, which is what keeps a MplusKassa or ShopCaisse catalogue sync from minting one row per article. Integration traffic is covered by RequestLog instead. (Verified: upvendo-backend app/Traits/LogsActivity.php lines 56-60.)
  • Bulk / mass write paths. updateMany, bulkUpsert, deleteByFilter and restoreByFilter deliberately bypass the repository lifecycle hooks for throughput, so a mass edit is not audited. (Verified: upvendo-backend app/Traits/LogsRepositoryActivity.php lines 15-18.)

On-Behalf Access

When a platform admin or a reseller opens a merchant's account (an AdminVendorOverride request that rebinds the tenant database), two things happen:

  1. Every audit row written during that request gets on_behalf set to internal or reseller, and its rendered title gains a "(Upvendo Support)" or "(Reseller)" suffix. The actor stays the real acting user. The backoffice also shows a warning chip on those rows.
  2. The visit itself writes one accessed_on_behalf row per (actor, merchant) per 3600-second window — including a purely read-only visit, which would otherwise leave no trace at all. It is deliberately not per request: that would be page-view auditing.

(Verified: upvendo-backend app/Http/Middleware/AdminVendorOverride.php lines 25-36, 110-113, 120-148; app/Traits/LogsActivity.php line 80; app/RawModels/ActivityLog.php lines 75-87.)

Business Logic

Data Flow

GET /back-office/settings/activity-logs/datatable   (middleware: permission:view-activity-logs)


ActivityLogController::dataTable (validates DatatableRequest)


ActivityLogService::dataTable


ActivityLogRepository::dataTable
├── build filter (actor / action / on_behalf / entity / created_at range)
├── paginate (page / take / sort, default sort created_at DESC)
├── search across loggable_name, actor_name, action
├── map each record → ActivityLog model (ActivityLogFactory)
├── serialize each → dataTableSerialize (10 fields)
└── attach filter_options (cached 300s)


{ total, take, page, max_page, data:[…], filter_options:{…} }

Request Parameters

DatatableRequest (app/Http/Requests/BackOffice/Settings/ActivityLog/DatatableRequest.php) validates:

ParameterRuleNotes
takenullable|integerPage size. The backoffice component uses 20.
pagenullable|integer
searchnullable|stringMatched against loggable_name, actor_name, action.
sortnullable|arrayBuilt from sort.key / sort.dir; defaults to created_at descending.
actor[]nullable|array of stringUser IDs.
action[]nullable|array, each Rule::in(ActivityLogActions)Stored enum values.
on_behalf[]nullable|array, each internal or resellerSee the note below.
entity[]nullable|array of string|alpha_dashShort class name, matched as a suffix of the stored FQCN loggable_type.
date_fromnullable|dateInclusive; start of day.
date_tonullable|date|after_or_equal:date_fromInclusive; end of day.

sort.key is lower-cased and then checked against a whitelist of five fields — created_at, action, actor_name, loggable_name, loggable_type — because the resolved sort array goes straight into the Mongo sort. Anything else falls back to created_at descending.

date_from / date_to are whole days parsed with Carbon (application timezone) and converted to UTCDateTime for the Mongo range.

Row Payload

dataTableSerialize returns ten fields per row: id, title, subtitle, action, entity, loggable_name, actor, actor_name, on_behalf, created_at.

  • title / subtitle are the rendered one-liner the list historically showed. subtitle is a pre-formatted timestamp string (M j, Y, g:i A T); created_at is ISO-8601.
  • action is the raw stored enum value; entity is the short class name (or the backend-translated "Unknown" when loggable_type is absent). Labels for both are the frontend's i18n keys, so nothing is translated twice.
  • on_behalf is nullable.
  • The current row layout is built from the structured fields, not from title — a pre-rendered string cannot be filtered, grouped or coloured client-side.

The response envelope also carries filter_options: { actions: string[], entities: string[], actors: [{ value, label }] }.

Title Generation

ActivityLog::getTitle builds: entity label + loggable name + translated action + "by" + actor name, with the on-behalf suffix appended to the actor name where applicable.

Relations

Depends On

  • Team / Users: The actor's ID and name come from the authenticated user; the stored name is used when the viewer is someone else.
  • Permissions: view-activity-logs (in Permissions::systemAdministration()) gates the endpoint.
  • Devices & Device Profiles, Items, Menus & Catalogue, Locations, Payment/Billing Profiles, Integrations, POS configuration: the sources of logged entries.

Business Rules

  • The datatable endpoint GET /back-office/settings/activity-logs/datatable requires the view-activity-logs permission via route middleware — that is the only unconditional gate. The Settings nav item declares the same literal permission string because CASL rules are manage all for every back-office user, so action/subject alone would show the item to everyone. Note the nav check passes when the user's permission list is empty or not yet hydrated (the super-admin fallback), in which case the menu item shows and the request 403s.
  • Entries are written only when there is an authenticated user (request()->user()); writers return early otherwise. That gate is what keeps inbound POS catalogue syncs from minting a row per article — integration traffic is covered by RequestLog. Device-profile transitions are the one exception (allowSystemActor: true) and fall back to actor system / System.
  • One audit row per record per action per request. Relation and bookkeeping re-saves that are part of the same user action collapse into a single row (an item edit saves the item again in syncModifierGroups() / syncUpsellGroups(), which used to mint two identical "Modified" rows ~270ms apart). The key is loggable_type + loggable_id + action, held in the request attribute bag, so it dies with the request. Writers that pass no loggable_id never de-duplicate. (Verified: upvendo-backend app/Traits/LogsActivity.php lines 62 and 92-131.)
  • Bulk write paths are not audited. updateMany, bulkUpsert, deleteByFilter and restoreByFilter bypass the repository lifecycle hooks, so a mass edit leaves no trail.
  • "You" is shown only when the viewer's user id equals the row's actor. Otherwise the row shows the stored actor_name, then the looked-up user's username, then "Unknown".
  • The "updated" action is persisted with the stored value modified.
  • Sensitive values never reach the trail: integration credential values are excluded (only the list of changed field names is stored), and a POS staff credential snapshot is an allow-list that omits pin_hash, pin_lookup and insz.
  • on_behalf can be filtered only by internal or reseller. The merchant's own activity stores null there, and a Mongo $in cannot express that — so there is no "own staff" filter option.
  • The list is sorted newest-first by default (created_at descending) and paginated with a "Load More" control; any filter or search change resets it to page 1 and clears what is on screen.
  • Writing an audit row never throws — a failure is logged and the operation it describes still succeeds.
  • Records are soft-deletable and are archived after 3 years by the ArchiveOldRecords command. (not-verified-here: whether any UI exposes deletion of log entries — no such action exists in the activity-log component.)

FAQs

  • "What actions appear in the activity log?" Menu and catalogue changes (items, categories, modifiers and modifier groups, variant groups, display groups, menus, offers, rewards, gift card templates, custom tax rates), locations, devices and device profiles, team and role changes, payment and billing profiles, third-party integration connect/disconnect/update, account-security events (passkeys, trusted devices), POS staff credentials and registers, customer exports, and Upvendo/reseller on-behalf access.
  • "Can I filter the log by user, date, or type?" Yes. The filter bar above the list offers Action, Type (entity), User, Source (Upvendo Support / Reseller) and a Date from / Date to range, plus a free-text search box. The same parameters are available on the API. Only values that actually occur in your trail are offered.
  • "Why do some entries say 'You'?" Because you performed them. "You" appears only when the signed-in viewer is the actor on that row; every other row shows the actual actor's name.
  • "What does the orange 'Upvendo Support' or 'Reseller' chip mean?" That action was taken inside your account by Upvendo staff or by your reseller rather than by one of your own users. The named actor is the real person who did it. You will also see an "accessed on behalf" entry the first time they enter your account in a given hour, even if they only looked around.
  • "Does the log show what fields changed?" Only partly. Entries store an optional snapshot, which the list UI does not display; there is no field-level old/new diff. Integration updates record which fields changed but never their values.
  • "Are logins recorded?" No. Sign-in, failed sign-in and password changes are not audited here. Removing a trusted device and adding/removing a passkey are.
  • "How long are entries kept?" Records are archived after 3 years (ArchiveOldRecords), split by year.

Troubleshooting

Problem: I can't see the Activity Log page

Causes:

  1. Your role does not include the View Activity Logs permission (view-activity-logs, under System Administration).
  2. Your permission list has not been hydrated in this browser session — the menu item then shows, but the request behind it returns 403.

Solutions:

  1. Ask an owner/administrator to add View Activity Logs to your role in Settings → Team → Roles.
  2. Sign out and back in to refresh your permissions, then retry.

Problem: An action I expected isn't in the log

Causes:

  1. The action was one of the deliberate exclusions — login, failed login or password change.
  2. The change came in from a POS/integration sync rather than from a signed-in user, so logging was skipped.
  3. The change was made through a bulk / mass write path, which bypasses the audit hooks.
  4. The entry is older and not yet loaded, or a filter is hiding it.

Solutions:

  1. Clear the filters and the search box, then use "Load More" to page back in time.
  2. For integration-driven changes, check RequestLog rather than the activity log.

Examples

API response shape

json
{
  "total": 156,
  "take": 20,
  "page": 1,
  "max_page": 8,
  "data": [
    {
      "id": "6650f1c2a4b3c2d1e0f00123",
      "title": "Device POS-01 created by You",
      "subtitle": "Jun 15, 2026, 2:30 PM CET",
      "action": "created",
      "entity": "Device",
      "loggable_name": "POS-01",
      "actor": "user-123",
      "actor_name": "You",
      "on_behalf": null,
      "created_at": "2026-06-15T14:30:00+01:00"
    },
    {
      "id": "6650f1c2a4b3c2d1e0f00124",
      "title": "DeviceProfile Front Counter modified by Jan Manager",
      "subtitle": "Jun 15, 2026, 2:15 PM CET",
      "action": "modified",
      "entity": "DeviceProfile",
      "loggable_name": "Front Counter",
      "actor": "user-456",
      "actor_name": "Jan Manager",
      "on_behalf": null,
      "created_at": "2026-06-15T14:15:00+01:00"
    },
    {
      "id": "6650f1c2a4b3c2d1e0f00125",
      "title": "ThirdPartyIntegration mpluskassa @ Central modified by Alex Support (Upvendo Support)",
      "subtitle": "Jun 15, 2026, 1:58 PM CET",
      "action": "modified",
      "entity": "ThirdPartyIntegration",
      "loggable_name": "mpluskassa @ Central",
      "actor": "user-789",
      "actor_name": "Alex Support (Upvendo Support)",
      "on_behalf": "internal",
      "created_at": "2026-06-15T13:58:00+01:00"
    }
  ],
  "filter_options": {
    "actions": ["created", "modified", "profile_changed"],
    "entities": ["Device", "DeviceProfile", "ThirdPartyIntegration"],
    "actors": [
      { "value": "user-123", "label": "Sam Owner" },
      { "value": "user-456", "label": "Jan Manager" }
    ]
  }
}

Stored record (device action)

json
{
  "loggable_name": "POS-01",
  "loggable_type": "App\\RawModels\\Device",
  "loggable_id": "device-456",
  "actor": "user-123",
  "actor_name": "Jan Manager",
  "action": "terminal_activated",
  "on_behalf": null,
  "snapshot": { "...": "device state at time of action" },
  "created_at": "2026-06-15T14:30:00Z"
}

Stored record (device profile transition)

json
{
  "loggable_name": "POS-01",
  "loggable_type": "App\\RawModels\\Device",
  "loggable_id": "device-456",
  "actor": "user-123",
  "actor_name": "Jan Manager",
  "action": "profile_changed",
  "on_behalf": null,
  "snapshot": {
    "id": "device-456",
    "device_profile_id": "profile-new",
    "previous_device_profile_id": "profile-old"
  },
  "created_at": "2026-06-15T14:30:00Z"
}

Stored record (on-behalf access)

json
{
  "loggable_name": "Alex Support",
  "loggable_type": "App\\RawModels\\Merchant",
  "loggable_id": "merchant-42",
  "actor": "user-789",
  "actor_name": "Alex Support",
  "action": "accessed_on_behalf",
  "on_behalf": "internal",
  "snapshot": { "merchant_id": "merchant-42" },
  "created_at": "2026-06-15T13:55:00Z"
}