Skip to content

Make the audit trail readable: real changes only, names instead of ids - #998

Merged
KrzysztofPajak merged 8 commits into
developfrom
fix/audit-detail-empty-store
Oct 30, 2026
Merged

KrzysztofPajak merged 8 commits into
developfrom
fix/audit-detail-empty-store

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Type: bugfix

Issue

Follow-up to #996. Using the audit trail on real data showed entries that did not make sense to a reader:

  • Saving an order showed OrderGuid: <binary> -> <binary> and OrderItemGuid: <binary> -> <binary> although only the billing first name changed. A Guid is stored as binary, and binary values were always listed as changed.
  • Saving a customer listed UserFields[...].Key lines and empty user fields being "removed".
  • Saving a news item or blog post listed a change of StartDateUtc that only lost its milliseconds in the form.
  • A changed reference showed bare ids, e.g. SpecificationAttributeOptionId: 6ae3... -> 6ae3..., and an order status as a number.
  • Adding or deleting an order note never showed in the order's change history, because a note is a record of its own.
  • The entry detail showed an empty "Store" (the staff member's store) for Main Admin, which read as if the record had no store.
  • The list had no visible way into an entry, and its quick search looked only at the staff e-mail without saying so.

Solution

  • Snapshot: Guids are compared as values, the key of a user field is not repeated as a field, and dates are compared to the second.
  • [AuditReference] on the reference properties of the domain. When a change is recorded, the value becomes "name (id)", so the entry stays readable after the referenced record is gone: Grey (6ae3...) -> Beige (6ae3...), Denmark (6ae3...). An element inside another record (a specification option, the state inside its country) names its list and the sibling property holding the owner; a reference matching another field than the id names it with Key (order status: Pending (10) -> Cancelled (40)). Enum properties show their name.
  • The names are read through IAuditRecordReader in Grand.Data (MongoDB and LiteDB), injected like the entry writer; BSON stays in Grand.Data.
  • Order notes: adding or deleting a note also records the change on the order.
  • Admin: the empty staff store is hidden on the detail, the list has a View button per row, and the quick search matches the staff e-mail or the record name.

Not referenced on purpose: pictures and downloads (they have no name), external and technical ids, and settings (their fields live in the Metadata JSON).

Breaking changes

None for callers of the public services. AuditTracker now takes an IAuditRecordReader (registered for MongoDB and LiteDB), and AuditEntrySearch.ActorEmail became SearchText.

Testing

  1. Unit tests: Grand.Infrastructure.Tests 263, including a domain-wide check that every [AuditReference] points to an entity with a name and that its list, owner and key exist. Full solution green (Marketing and Authentication pass when run alone; they flake in parallel as before).
  2. On a fresh install with sample data, save in the admin without changes the first record of: product, category, brand, collection, customer, customer group, discount, page, vendor, store, language, currency, country, affiliate, message template, news, blog, e-mail account, product attribute, specification attribute, menu, product review, and the catalog, content, vendor, media, customer, push and admin search settings. Second save: no entries anywhere. First save: only real writes of the forms.
  3. Order: change the billing first name - only BillingAddress.FirstName (plus the "Address has been edited" note the app adds). Cancel it - OrderStatusId: Pending (10) -> Cancelled (40), payment Pending (0) -> Canceled (50).
  4. Product: change a specification option - SpecificationAttributeOptionId: Black (...) -> White (...).
  5. Order note: add and delete one - both show in the order's Change history.
  6. Audit log list: the View button opens the entry; searching "audit brand" finds the entry of a brand by its name.

Found while testing, real writes of the forms (not audit noise) left for separate PRs: saving a vendor without changes clears its address, and saving a store strips the port from its host name.

support added 7 commits October 29, 2026 20:23
The Store field is the store of the staff member who made the change. Main Admin belongs to no store, so it showed an empty Store next to the changed record, which read as if the record had no store. It is now shown only when set, like the other optional fields.
A Guid is stored as binary, so OrderGuid and OrderItemGuid showed as '<binary> -> <binary>' on every save of an order; Guids are now compared as values. The key of a user field was repeated as a field of its own, so adding or dropping an empty user field looked like a change. Dates are compared to the second, as a form that posts a date back drops its milliseconds.
A note is a record of its own, so adding or deleting one never showed on the order. The note actions now also record the change on the order itself.
A reference was recorded as a bare id (SpecificationAttributeOptionId: 6ae3... -> 6ae3...), which tells a
reader nothing. A property now declares what it refers to with [AuditReference(typeof(Brand))]; an element
inside another record (a specification option) names the list and the sibling property holding its owner. When
the change is recorded the value becomes "Grey (6ae3...)", so the entry stays readable after the referenced
record is gone. Enum properties show their name the same way.

The names are read through IAuditRecordReader in Grand.Data (MongoDB and LiteDB), injected like the entry
writer. Marked for now: the brand, vendor, layout, delivery date, tax category, warehouse, categories,
collections, specifications, stores and customer groups of a product; the parent, stores and groups of a
category; the store and customer of an order; the vendor, stores and groups of a customer.
The quick search looked only at the staff member's e-mail and did not say so. It now matches the e-mail or the name of the changed record, as the search of the other admin lists does, and is labelled that way.
Every property of an entity that refers to a named record now carries [AuditReference]: stores, customer groups, discounts, customers, products, orders, warehouses, layouts, languages, currencies, countries and the state inside its country, and more. A reference that matches another field than the id, such as the order status number, names that field with Key. A test checks every mark of the domain: the target is an entity with a name, and its list, owner and key exist. Pictures, downloads, external ids and settings stay as they are.
Copilot AI balanced review requested due to automatic review settings October 29, 2026 21:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

Comment on lines +117 to +150
foreach (var property in type.GetProperties())
{
if (property.GetCustomAttributes(typeof(AuditReferenceAttribute), false).FirstOrDefault() is not AuditReferenceAttribute mark)
continue;

var name = $"{type.Name}.{property.Name}";
var target = mark.Target;
var isList = property.PropertyType != typeof(string)
&& typeof(System.Collections.IEnumerable).IsAssignableFrom(property.PropertyType);
if (target.IsEnum)
continue;
if (!typeof(Grand.Domain.BaseEntity).IsAssignableFrom(target))
problems.Add($"{name}: {target.Name} is not an entity");
if (property.PropertyType != typeof(string) && !isList && mark.Key == null)
problems.Add($"{name}: a {property.PropertyType.Name} value needs a Key");
if (mark.Key != null && target.GetProperty(mark.Key) == null)
problems.Add($"{name}: {target.Name} has no {mark.Key}");
if (mark.Owner != null && type.GetProperty(mark.Owner) == null)
problems.Add($"{name}: {type.Name} has no owner property {mark.Owner}");

var named = target;
if (mark.List != null)
{
var list = target.GetProperty(mark.List);
named = list?.PropertyType.GetGenericArguments().FirstOrDefault();
if (named == null)
problems.Add($"{name}: {target.Name} has no list {mark.List}");
if (mark.Owner == null)
problems.Add($"{name}: an element of a list needs an Owner");
}

if (named != null && named.GetProperty(AuditReferences.NameProperty(named)) == null)
problems.Add($"{name}: {named.Name} has no name property");
}
Comment on lines +39 to +42
catch (Exception)
{
//keep the stored values
}
- Order-note entries carry the order's store (EntityStoreId), so the audit
  log's store filter and the "Applies to" field include them
- A cut order note is marked as truncated, through AuditDiff.Change
- Truncated values are not named (a cut id list no longer parses), and named
  values are cut to MaxValueLength again and marked
- The record reader reads only the name field (projection on MongoDB);
  property lookups are cached by the path without element keys
- The grid's View text goes through <grid-text>
@KrzysztofPajak
KrzysztofPajak merged commit e54da88 into develop Oct 30, 2026
9 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the fix/audit-detail-empty-store branch October 30, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants