Repository navigation
Make the audit trail readable: real changes only, names instead of ids - #998
Merged
Merged
Conversation
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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Type: bugfix
Issue
Follow-up to #996. Using the audit trail on real data showed entries that did not make sense to a reader:
OrderGuid: <binary> -> <binary>andOrderItemGuid: <binary> -> <binary>although only the billing first name changed. A Guid is stored as binary, and binary values were always listed as changed.UserFields[...].Keylines and empty user fields being "removed".StartDateUtcthat only lost its milliseconds in the form.SpecificationAttributeOptionId: 6ae3... -> 6ae3..., and an order status as a number.Solution
[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 withKey(order status:Pending (10) -> Cancelled (40)). Enum properties show their name.IAuditRecordReaderin Grand.Data (MongoDB and LiteDB), injected like the entry writer; BSON stays in Grand.Data.Not referenced on purpose: pictures and downloads (they have no name), external and technical ids, and settings (their fields live in the
MetadataJSON).Breaking changes
None for callers of the public services.
AuditTrackernow takes anIAuditRecordReader(registered for MongoDB and LiteDB), andAuditEntrySearch.ActorEmailbecameSearchText.Testing
Grand.Infrastructure.Tests263, 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).BillingAddress.FirstName(plus the "Address has been edited" note the app adds). Cancel it -OrderStatusId: Pending (10) -> Cancelled (40), paymentPending (0) -> Canceled (50).SpecificationAttributeOptionId: Black (...) -> White (...).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.