Appearance
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
actoris compared to the signed-in user's id. Any other row shows the storedactor_name, falling back to the looked-up user's username and then to "Unknown". (Verified:upvendo-backendapp/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_behalfrecords the capacity —internal(Upvendo Support) orreseller. See On-Behalf Access. - Loggable Entity: The record the action applied to, stored as
loggable_name(display name),loggable_type(fully-qualified class name) andloggable_id. The entity label shown in the UI is the last\-delimited segment ofloggable_type(e.g.Device,DeviceProfile), or the translated "Unknown" when the type is empty. - Snapshot: An optional
snapshotarray 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_optionsblock 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::dataTable→ActivityLogService::dataTable→ActivityLogRepository::dataTable. (The controller injectsActivityLogServicedirectly — there is noActivityLogOrchestratorlayer.) - Soft Delete & Archival: The
activity_logscollection uses soft deletes and is archived; theArchiveOldRecordscommand 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 namesettings-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 inPermissions::systemAdministration()as "View Activity Logs" (app/Constants/Permissions.phplines 389 and 935-939). The API route carriespermission:view-activity-logsmiddleware (routes/api/backoffice/settings/activity-logs.phplines 8-9). - Navigation: Settings menu item "Activity Log" (icon
tabler-file-report), which declaresaction: 'view',subject: 'activity-logs'and the literal permission stringview-activity-logs. The literal string is needed because CASL rules aremanage allfor every back-office user, so action/subject alone would show the item to everyone. (Verified:upvendo-backofficesrc/navigation/settings/index.ts lines 89-99.)
Stored Fields
These are the fields stored on each ActivityLog record (app/RawModels/ActivityLog.php).
Action
| Property | Value |
|---|---|
| Field ID | action |
| Type | String (enum value from ActivityLogActions) |
Description: The type of action performed. See Action Types.
Actor
| Property | Value |
|---|---|
| Field ID | actor |
| Type | String (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
| Property | Value |
|---|---|
| Field ID | actor_name |
| Type | String |
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
| Property | Value |
|---|---|
| Field ID | loggable_name |
| Type | String |
Description: Display name of the affected record (e.g. the device, item or user name).
Loggable Type
| Property | Value |
|---|---|
| Field ID | loggable_type |
| Type | String (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
| Property | Value |
|---|---|
| Field ID | loggable_id |
| Type | String, 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
| Property | Value |
|---|---|
| Field ID | on_behalf |
| Type | String, 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
| Property | Value |
|---|---|
| Field ID | snapshot |
| Type | Object / 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
| Property | Value |
|---|---|
| Field IDs | created_at, updated_at, deleted_at |
| Type | DateTime (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 case | Stored value |
|---|---|
Created | created |
Updated | modified |
Deleted | deleted |
Restored | restored |
ForceDeleted | force_deleted |
TerminalActivated | terminal_activated |
TerminalDeactivated | terminal_deactivated |
ProfileAssigned | profile_assigned |
ProfileChanged | profile_changed |
ProfileRemoved | profile_removed |
RolesAssigned | roles_assigned |
LocationsAssigned | locations_assigned |
Invited | invited |
Connected | connected |
Disconnected | disconnected |
SetAsDefault | set_as_default |
Exported | exported |
AccessedOnBehalf | accessed_on_behalf |
PasskeyAdded | passkey_added |
PasskeyRemoved | passkey_removed |
TrustedDeviceRemoved | trusted_device_removed |
PinReset | pin_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.
| Domain | Records | Actions | Source |
|---|---|---|---|
| Menu & catalogue | Category, Modifier, Modifier Group, Variant Group, Display Group, Menu, Offer, Reward, Gift Card Template, Custom Tax Rate | created, modified, deleted, restored, force_deleted | repository hooks via app/Traits/LogsRepositoryActivity.php |
| Items | Item | created, modified, deleted, restored, force_deleted (with a full snapshot) | app/Repositories/ItemRepository.php |
| Locations | Location | created, modified, deleted, restored, force_deleted | app/Repositories/LocationRepository.php (hooks) |
| Devices | Device | created, modified, deleted, terminal_activated, terminal_deactivated | app/Services/BackOffice/DeviceService.php |
| Device profiles | DeviceProfile; device→profile transitions | created, modified, deleted; profile_assigned, profile_changed, profile_removed | app/Services/BackOffice/DeviceProfileService.php, app/Repositories/DeviceRepository.php |
| Team & access | User, Role | invited, deleted, roles_assigned, locations_assigned; role created / modified / deleted | app/Services/BackOffice/Settings/TeamService.php |
| Account security | User | passkey_added, passkey_removed, trusted_device_removed | app/Services/PasskeyService.php, app/Services/AuthService.php |
| Payment & billing profiles | PaymentProfile, BillingProfile | created, modified, deleted, set_as_default | app/Services/BackOffice/Settings/PaymentProfileService.php, .../BillingProfileService.php |
| Third-party integrations | ThirdPartyIntegration | connected, disconnected, modified (recording which fields changed) | app/Services/BackOffice/AbstractThirdPartyIntegrationService.php |
| POS configuration | PosStaffCredential; PosRegister, FdmDevice | created, modified, pin_reset, deleted; register/FDM device lifecycle | app/Services/BackOffice/Pos/PosStaffCredentialAdminService.php; PosRegisterRepository, FdmDeviceRepository (hooks) |
| Customers | Customer export | exported (with row_count and the export filter) | app/Services/BackOffice/CustomerService.php |
| On-behalf access | Merchant | accessed_on_behalf | app/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 istrusted_device_removed; sign-in andupdatePassword()write nothing. - Inbound POS / integration sync writes.
logActivity()returns early whenrequest()->user()is null, which is what keeps a MplusKassa or ShopCaisse catalogue sync from minting one row per article. Integration traffic is covered byRequestLoginstead. (Verified:upvendo-backendapp/Traits/LogsActivity.php lines 56-60.) - Bulk / mass write paths.
updateMany,bulkUpsert,deleteByFilterandrestoreByFilterdeliberately bypass the repository lifecycle hooks for throughput, so a mass edit is not audited. (Verified:upvendo-backendapp/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:
- Every audit row written during that request gets
on_behalfset tointernalorreseller, 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. - The visit itself writes one
accessed_on_behalfrow 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:
| Parameter | Rule | Notes |
|---|---|---|
take | nullable|integer | Page size. The backoffice component uses 20. |
page | nullable|integer | |
search | nullable|string | Matched against loggable_name, actor_name, action. |
sort | nullable|array | Built from sort.key / sort.dir; defaults to created_at descending. |
actor[] | nullable|array of string | User IDs. |
action[] | nullable|array, each Rule::in(ActivityLogActions) | Stored enum values. |
on_behalf[] | nullable|array, each internal or reseller | See the note below. |
entity[] | nullable|array of string|alpha_dash | Short class name, matched as a suffix of the stored FQCN loggable_type. |
date_from | nullable|date | Inclusive; start of day. |
date_to | nullable|date|after_or_equal:date_from | Inclusive; 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/subtitleare the rendered one-liner the list historically showed.subtitleis a pre-formatted timestamp string (M j, Y, g:i A T);created_atis ISO-8601.actionis the raw stored enum value;entityis the short class name (or the backend-translated "Unknown" whenloggable_typeis absent). Labels for both are the frontend's i18n keys, so nothing is translated twice.on_behalfis 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(inPermissions::systemAdministration()) gates the endpoint. - Devices & Device Profiles, Items, Menus & Catalogue, Locations, Payment/Billing Profiles, Integrations, POS configuration: the sources of logged entries.
Related Features
Business Rules
- The datatable endpoint
GET /back-office/settings/activity-logs/datatablerequires theview-activity-logspermission via route middleware — that is the only unconditional gate. The Settings nav item declares the same literal permission string because CASL rules aremanage allfor 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 byRequestLog. Device-profile transitions are the one exception (allowSystemActor: true) and fall back to actorsystem/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 isloggable_type+loggable_id+action, held in the request attribute bag, so it dies with the request. Writers that pass nologgable_idnever de-duplicate. (Verified:upvendo-backendapp/Traits/LogsActivity.php lines 62 and 92-131.) - Bulk write paths are not audited.
updateMany,bulkUpsert,deleteByFilterandrestoreByFilterbypass 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 storedactor_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_lookupandinsz. on_behalfcan be filtered only byinternalorreseller. The merchant's own activity storesnullthere, and a Mongo$incannot express that — so there is no "own staff" filter option.- The list is sorted newest-first by default (
created_atdescending) 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
ArchiveOldRecordscommand. (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:
- Your role does not include the View Activity Logs permission (
view-activity-logs, under System Administration). - Your permission list has not been hydrated in this browser session — the menu item then shows, but the request behind it returns 403.
Solutions:
- Ask an owner/administrator to add View Activity Logs to your role in Settings → Team → Roles.
- Sign out and back in to refresh your permissions, then retry.
Problem: An action I expected isn't in the log
Causes:
- The action was one of the deliberate exclusions — login, failed login or password change.
- The change came in from a POS/integration sync rather than from a signed-in user, so logging was skipped.
- The change was made through a bulk / mass write path, which bypasses the audit hooks.
- The entry is older and not yet loaded, or a filter is hiding it.
Solutions:
- Clear the filters and the search box, then use "Load More" to page back in time.
- For integration-driven changes, check
RequestLograther 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"
}