From 2eb833f90947376793a589488bfb67b5704ace42 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 29 Oct 2026 20:23:27 +0100 Subject: [PATCH 1/8] Hide the empty staff store on the audit entry detail 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. --- .../Areas/Admin/Views/AuditLog/Detail.cshtml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/Detail.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/Detail.cshtml index 4baf101d5..bd94acbc8 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/Detail.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/Detail.cshtml @@ -39,10 +39,13 @@
@Model.AppliesTo
} -
- @Loc["Admin.System.AuditLog.Fields.StoreId"] -
@entry.StoreId
-
+ @if (!string.IsNullOrEmpty(entry.StoreId)) + { +
+ @Loc["Admin.System.AuditLog.Fields.StoreId"] +
@entry.StoreId
+
+ }
@Loc["Admin.System.AuditLog.Fields.IpAddress"]
@entry.IpAddress
From 056a3a2f34b07d47bc41e2692923dfbbfa3ae001 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 29 Oct 2026 21:17:57 +0100 Subject: [PATCH 2/8] Stop recording changes nobody made A Guid is stored as binary, so OrderGuid and OrderItemGuid showed as ' -> ' 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. --- src/Core/Grand.Data/Audit/AuditSnapshot.cs | 40 +++++++++++++---- .../Audit/AuditDiffTests.cs | 45 ++++++++++++++++++- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/Core/Grand.Data/Audit/AuditSnapshot.cs b/src/Core/Grand.Data/Audit/AuditSnapshot.cs index aa16ef2f9..fd781d745 100644 --- a/src/Core/Grand.Data/Audit/AuditSnapshot.cs +++ b/src/Core/Grand.Data/Audit/AuditSnapshot.cs @@ -56,7 +56,13 @@ private static void Flatten(string path, BsonValue value, Dictionary v.IsBsonDocument || v.IsBsonArray): for (var i = 0; i < array.Count; i++) - Flatten($"{path}[{ElementKey(array[i], i)}]", array[i], leaves); + { + //the key of a user field is already in its path; repeating it as a field shows a change of nothing + var element = array[i] is BsonDocument document && IsKeyed(document) + ? new BsonDocument(document.Where(e => e.Name is not ("Key" or "StoreId"))) + : array[i]; + Flatten($"{path}[{ElementKey(array[i], i)}]", element, leaves); + } break; default: //a scalar, or a list of scalars kept as one value; when element ids repeat, the first wins @@ -69,13 +75,25 @@ private static string ElementKey(BsonValue element, int index) { if (element is not BsonDocument document) return index.ToString(); - if ((document.TryGetValue("_id", out var id) || document.TryGetValue("Id", out id)) && !id.IsBsonNull) + if (HasId(document, out var id)) return $"id={id}"; - if (!document.TryGetValue("Key", out var key) || !key.IsString || key.AsString == "") + if (!IsKeyed(document)) return index.ToString(); + var key = document["Key"].AsString; return document.TryGetValue("StoreId", out var storeId) && storeId.IsString && storeId.AsString != "" - ? $"{key.AsString}@{storeId.AsString}" - : key.AsString; + ? $"{key}@{storeId.AsString}" + : key; + } + + private static bool HasId(BsonDocument document, out BsonValue id) + { + return (document.TryGetValue("_id", out id) || document.TryGetValue("Id", out id)) && !id.IsBsonNull; + } + + //a key/value pair such as a user field: matched by its key (once per store) instead of its position + private static bool IsKeyed(BsonDocument document) + { + return !HasId(document, out _) && document.TryGetValue("Key", out var key) && key.IsString && key.AsString != ""; } private static BsonDocument TryParse(string json) @@ -94,10 +112,16 @@ private static string Format(BsonValue value) { if (value.IsString) return value.AsString; - if (value.IsBsonBinaryData) - return Binary; + //a Guid is stored as binary (CSharpLegacy, see MongoDBStartupBase) but is a value like any other + if (value is BsonBinaryData binary) + return binary.SubType switch { + BsonBinarySubType.UuidLegacy => GuidConverter.FromBytes(binary.Bytes, GuidRepresentation.CSharpLegacy).ToString(), + BsonBinarySubType.UuidStandard => GuidConverter.FromBytes(binary.Bytes, GuidRepresentation.Standard).ToString(), + _ => Binary + }; + //to the second: a form that posts a date back drops its milliseconds, which is no change to a person if (value.IsValidDateTime) - return value.ToUniversalTime().ToString("o"); + return value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); if (value.IsBsonArray) return value.ToJson(); return value.ToString(); diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs index c30de6e57..3266d4ed3 100644 --- a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditDiffTests.cs @@ -145,6 +145,20 @@ public void Compare_KeyValueArray_AddedKey_ShowsValue() Assert.AreEqual("c9", changes.Single(c => c.Path == "UserFields[ImpersonatedCustomerId].Value").NewValue); } + [TestMethod] + public void Compare_KeyValueArray_SavedWithoutChanges_OnlyRealValues() + { + //a customer saved without edits: empty user fields dropped, a default one added + var before = new BsonDocument("UserFields", new BsonArray { UserField("FirstName", ""), UserField("LastName", "") }); + var after = new BsonDocument("UserFields", new BsonArray { UserField("TwoFactorEnabled", "False") }); + + var change = Compare(before, after).Single(); + + Assert.AreEqual("UserFields[TwoFactorEnabled].Value", change.Path); + Assert.IsNull(change.OldValue); + Assert.AreEqual("False", change.NewValue); + } + [TestMethod] public void Compare_KeyValueArray_SensitiveKey_Masked() { @@ -155,8 +169,8 @@ public void Compare_KeyValueArray_SensitiveKey_Masked() var changes = Compare(before, after); - //the changed token, and the Key and Value of the added secret - Assert.AreEqual(3, changes.Count); + //the changed token and the value of the added secret + Assert.AreEqual(2, changes.Count); foreach (var change in changes) { Assert.IsTrue(change.Masked, change.Path); @@ -192,6 +206,33 @@ public void Compare_LongValue_Truncated() Assert.IsTrue(changes[0].Truncated); } + [TestMethod] + public void Compare_DateDifferingInMilliseconds_Unchanged() + { + var stored = new DateTime(2026, 10, 26, 19, 34, 23, 497, DateTimeKind.Utc); + + Assert.AreEqual(0, Compare(new BsonDocument("StartDateUtc", stored), + new BsonDocument("StartDateUtc", stored.AddMilliseconds(-497))).Count); + Assert.AreEqual("2026-10-26T19:34:24Z", Compare(new BsonDocument("StartDateUtc", stored), + new BsonDocument("StartDateUtc", stored.AddSeconds(1))).Single().NewValue); + } + + [TestMethod] + public void Compare_Guid_ComparedAsValue() + { + var orderGuid = Guid.NewGuid(); + BsonDocument Order(Guid guid, string name) => new() { + { "OrderGuid", new BsonBinaryData(guid, GuidRepresentation.CSharpLegacy) }, { "FirstName", name } + }; + + var unchanged = Compare(Order(orderGuid, "Anna"), Order(orderGuid, "Ann")); + var changed = Compare(Order(orderGuid, "Anna"), Order(Guid.Empty, "Anna")); + + Assert.AreEqual("FirstName", unchanged.Single().Path); + Assert.AreEqual(orderGuid.ToString(), changed.Single().OldValue); + Assert.AreEqual(Guid.Empty.ToString(), changed.Single().NewValue); + } + [TestMethod] public void Compare_Binary_NotCompared_ListedAsChanged() { From 4348959b35b29ecfe8d2c9ea4118ca599e4996db Mon Sep 17 00:00:00 2001 From: support Date: Thu, 29 Oct 2026 21:17:57 +0100 Subject: [PATCH 3/8] Open an audit entry from the list with a View button --- .../Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml index d83f3bb7f..3b0176130 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml @@ -45,6 +45,13 @@ + + + + @Loc["Admin.Common.View"] + + + From 635851ce9768fbd85cb5958b32cc250770005c45 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 29 Oct 2026 21:17:59 +0100 Subject: [PATCH 4/8] Show order notes in the order's change history 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. --- .../BaseOrderManagementControllerTests.cs | 19 +++++++++++++++++-- .../BaseOrderManagementController.cs | 13 +++++++++++-- .../Audit/AuditEventRecorder.cs | 18 ++++++++++++++++++ .../Audit/IAuditEventRecorder.cs | 7 +++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs index 723ed60c8..208d8694c 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs @@ -22,6 +22,7 @@ using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Grand.Web.Common.Audit; using Moq; using Grand.Web.Common.Localization; @@ -444,6 +445,20 @@ public async Task AddProductToOrderSearch_ScopeDenies_ReturnsGridError() Assert.IsNotNull(GridErrors(result)); } + [TestMethod] + public async Task OrderNoteAdd_RecordedOnTheOrder() + { + var order = new Order { Id = "o1", OrderNumber = 7 }; + _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); + _scopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(true); + _orderNoteViewModelServiceMock.Setup(v => v.InsertOrderNote(order, null, false, "msg")).ReturnsAsync(true); + var recorder = new Mock(); + + await _controller.OrderNoteAdd("o1", null, false, "msg", recorder.Object); + + recorder.Verify(r => r.OwnedChange("Order", "o1", "7", "OrderNote", null, "msg"), Times.Once); + } + [TestMethod] public async Task OrderNoteAdd_FileRefused_ReturnsResultFalseWithMessage() { @@ -452,7 +467,7 @@ public async Task OrderNoteAdd_FileRefused_ReturnsResultFalseWithMessage() _scopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(true); _orderNoteViewModelServiceMock.Setup(v => v.InsertOrderNote(order, "d1", false, "msg")).ReturnsAsync(false); - var result = await _controller.OrderNoteAdd("o1", "d1", false, "msg"); + var result = await _controller.OrderNoteAdd("o1", "d1", false, "msg", new Mock().Object); var value = (result as JsonResult)?.Value; Assert.IsFalse((bool)value!.GetType().GetProperty("Result")!.GetValue(value)!); @@ -570,7 +585,7 @@ public async Task OrderNoteAdd_ScopeDenies_ReturnsJsonResultFalse() _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); _scopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(false); - var result = await _controller.OrderNoteAdd("o1", null, false, "msg"); + var result = await _controller.OrderNoteAdd("o1", null, false, "msg", new Mock().Object); var json = result as JsonResult; Assert.IsNotNull(json); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs index 7c63ef23e..ba3e6d31d 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs @@ -6,6 +6,7 @@ using Grand.Business.Core.Utilities.Catalog; using Grand.Domain.Catalog; using Grand.Domain.Orders; +using Grand.Web.Common.Audit; using Grand.Domain.Permissions; using Grand.Infrastructure; using Grand.Mediator; @@ -659,7 +660,8 @@ public async Task OrderNotesSelect(string orderId, Grand.Web.Comm [PermissionAuthorizeAction(PermissionActionName.Edit)] [HttpPost] - public async Task OrderNoteAdd(string orderId, string downloadId, bool displayToCustomer, string message) + public async Task OrderNoteAdd(string orderId, string downloadId, bool displayToCustomer, string message, + [FromServices] IAuditEventRecorder auditEventRecorder) { var order = await OrderService.GetOrderById(orderId); if (order == null || !await Scope.HasAccess(order)) return Json(new { Result = false }); @@ -670,18 +672,25 @@ public async Task OrderNoteAdd(string orderId, string downloadId, Message = TranslationService.GetResource("Admin.Orders.OrderNotes.DownloadNotOfOrder") }); + //a note is a record of its own; this puts it in the order's change history + await auditEventRecorder.OwnedChange(nameof(Order), order.Id, order.OrderNumber.ToString(), "OrderNote", null, + message); return Json(new { Result = true }); } [PermissionAuthorizeAction(PermissionActionName.Delete)] [HttpPost] - public async Task OrderNoteDelete(string id, string orderId) + public async Task OrderNoteDelete(string id, string orderId, + [FromServices] IAuditEventRecorder auditEventRecorder) { var order = await OrderService.GetOrderById(orderId) ?? throw new ArgumentException("No order found with the specified id"); if (!await Scope.HasAccess(order)) return Json(new { Result = false }); + var note = (await OrderService.GetOrderNotes(order.Id)).FirstOrDefault(n => n.Id == id); await orderNoteViewModelService.DeleteOrderNote(order, id); + await auditEventRecorder.OwnedChange(nameof(Order), order.Id, order.OrderNumber.ToString(), "OrderNote", + note?.Note, null); return new JsonResult(""); } diff --git a/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs b/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs index 4d566a999..33812291d 100644 --- a/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs +++ b/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs @@ -3,6 +3,7 @@ using Grand.Domain.Audit; using Grand.Domain.Customers; using Grand.Infrastructure; +using Grand.Infrastructure.Audit; using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -88,6 +89,23 @@ public Task Plugin(string pluginName, string change) }); } + public Task OwnedChange(string entityType, string entityId, string entityName, string path, string oldValue, + string newValue) + { + return Record(AuditKind.Update, CurrentStaff(), entry => + { + entry.EntityType = entityType; + entry.EntityId = entityId; + entry.EntityName = entityName; + entry.Changes = [new AuditChange { Path = path, OldValue = Shorten(oldValue), NewValue = Shorten(newValue) }]; + }); + } + + private static string Shorten(string value) + { + return value is { Length: > AuditDiff.MaxValueLength } ? value[..AuditDiff.MaxValueLength] : value; + } + private async Task RecordSignInEvent(AuditKind kind, Customer customer, string reason) { try diff --git a/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs b/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs index 551471153..ca947816b 100644 --- a/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs +++ b/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs @@ -40,4 +40,11 @@ public interface IAuditEventRecorder /// Records a plugin installed, uninstalled, removed or uploaded by the current staff member /// Task Plugin(string pluginName, string change); + + /// + /// Records, on a record, a change made through a separate record that belongs to it (a note of an order), so it + /// shows in the owner's change history + /// + Task OwnedChange(string entityType, string entityId, string entityName, string path, string oldValue, + string newValue); } From 2104e910428c2b2697d4135b59903c797e923194 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 29 Oct 2026 21:35:45 +0100 Subject: [PATCH 5/8] Name the records a changed field refers to 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. --- src/Core/Grand.Data/Audit/AuditSnapshot.cs | 7 +- .../Grand.Data/Audit/IAuditRecordReader.cs | 12 ++ .../LiteDb/LiteDBAuditRecordReader.cs | 19 +++ .../Mongo/MongoAuditRecordReader.cs | 19 +++ .../Audit/AuditReferenceAttribute.cs | 20 +++ src/Core/Grand.Domain/Catalog/Category.cs | 5 + src/Core/Grand.Domain/Catalog/Product.cs | 13 ++ .../Grand.Domain/Catalog/ProductCategory.cs | 3 + .../Grand.Domain/Catalog/ProductCollection.cs | 3 + .../Catalog/ProductSpecificationAttribute.cs | 4 + src/Core/Grand.Domain/Customers/Customer.cs | 6 + src/Core/Grand.Domain/Orders/Order.cs | 4 + .../Audit/AuditReferences.cs | 131 ++++++++++++++++++ .../Audit/AuditTracker.cs | 16 ++- .../Startup/StartupApplication.cs | 4 + .../Audit/AuditReferencesTests.cs | 92 ++++++++++++ .../Audit/AuditTrackerTests.cs | 3 +- .../Audit/AuditingLiteDBRepositoryTests.cs | 3 +- 18 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 src/Core/Grand.Data/Audit/IAuditRecordReader.cs create mode 100644 src/Core/Grand.Data/LiteDb/LiteDBAuditRecordReader.cs create mode 100644 src/Core/Grand.Data/Mongo/MongoAuditRecordReader.cs create mode 100644 src/Core/Grand.Domain/Audit/AuditReferenceAttribute.cs create mode 100644 src/Core/Grand.Infrastructure/Audit/AuditReferences.cs create mode 100644 src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs diff --git a/src/Core/Grand.Data/Audit/AuditSnapshot.cs b/src/Core/Grand.Data/Audit/AuditSnapshot.cs index fd781d745..ace63c2ee 100644 --- a/src/Core/Grand.Data/Audit/AuditSnapshot.cs +++ b/src/Core/Grand.Data/Audit/AuditSnapshot.cs @@ -22,7 +22,12 @@ public static class AuditSnapshot public static IDictionary Take(T entity) where T : BaseEntity { - return Flatten(entity.ToBsonDocument(), typeof(T) == typeof(Setting)); + return Take(entity, typeof(T)); + } + + public static IDictionary Take(object entity, Type entityType) + { + return Flatten(entity.ToBsonDocument(entityType), entityType == typeof(Setting)); } public static IDictionary Flatten(BsonDocument document, bool isSetting) diff --git a/src/Core/Grand.Data/Audit/IAuditRecordReader.cs b/src/Core/Grand.Data/Audit/IAuditRecordReader.cs new file mode 100644 index 000000000..74450b1bb --- /dev/null +++ b/src/Core/Grand.Data/Audit/IAuditRecordReader.cs @@ -0,0 +1,12 @@ +namespace Grand.Data.Audit; + +/// +/// Reads one stored record as an audit snapshot, to name the record a changed field refers to +/// +public interface IAuditRecordReader +{ + /// + /// The record of with the id as "path → value", or null when there is none + /// + Task> Read(Type entityType, string id); +} diff --git a/src/Core/Grand.Data/LiteDb/LiteDBAuditRecordReader.cs b/src/Core/Grand.Data/LiteDb/LiteDBAuditRecordReader.cs new file mode 100644 index 000000000..0ef429c93 --- /dev/null +++ b/src/Core/Grand.Data/LiteDb/LiteDBAuditRecordReader.cs @@ -0,0 +1,19 @@ +using Grand.Data.Audit; +using LiteDB; + +namespace Grand.Data.LiteDb; + +/// +/// Resolves the database only when a record is read, so it can be registered before installation +/// +public class LiteDBAuditRecordReader(Func database) : IAuditRecordReader +{ + public Task> Read(Type entityType, string id) + { + var db = database(); + var document = db.GetCollection(entityType.Name).FindById(new BsonValue(id)); + //through the entity, so the snapshot has the same shape as on MongoDB + var entity = document == null ? null : db.Mapper.ToObject(entityType, document); + return Task.FromResult(entity == null ? null : AuditSnapshot.Take(entity, entityType)); + } +} diff --git a/src/Core/Grand.Data/Mongo/MongoAuditRecordReader.cs b/src/Core/Grand.Data/Mongo/MongoAuditRecordReader.cs new file mode 100644 index 000000000..01e719bda --- /dev/null +++ b/src/Core/Grand.Data/Mongo/MongoAuditRecordReader.cs @@ -0,0 +1,19 @@ +using Grand.Data.Audit; +using Grand.Domain.Configuration; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Grand.Data.Mongo; + +/// +/// Resolves the database only when a record is read, so it can be registered before installation +/// +public class MongoAuditRecordReader(Func database) : IAuditRecordReader +{ + public async Task> Read(Type entityType, string id) + { + var document = await database().GetCollection(entityType.Name) + .Find(new BsonDocument("_id", id)).FirstOrDefaultAsync(); + return AuditSnapshot.Flatten(document, entityType == typeof(Setting)); + } +} diff --git a/src/Core/Grand.Domain/Audit/AuditReferenceAttribute.cs b/src/Core/Grand.Domain/Audit/AuditReferenceAttribute.cs new file mode 100644 index 000000000..0d975169f --- /dev/null +++ b/src/Core/Grand.Domain/Audit/AuditReferenceAttribute.cs @@ -0,0 +1,20 @@ +namespace Grand.Domain.Audit; + +/// +/// Marks a property that refers to another record (or holds an enum value as a number), so the audit log shows +/// "name (id)" instead of a bare id. is the referenced entity or the enum; +/// names the list inside the target that holds the referenced element, whose owning +/// target record is given by the sibling property named in . +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class AuditReferenceAttribute(Type target, string list = null) : Attribute +{ + public Type Target { get; } = target; + + public string List { get; } = list; + + /// + /// The sibling property holding the id of the target record that contains + /// + public string Owner { get; set; } +} diff --git a/src/Core/Grand.Domain/Catalog/Category.cs b/src/Core/Grand.Domain/Catalog/Category.cs index e84eb54bc..a6c8560b4 100644 --- a/src/Core/Grand.Domain/Catalog/Category.cs +++ b/src/Core/Grand.Domain/Catalog/Category.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Customers; +using Grand.Domain.Audit; using Grand.Domain.Localization; using Grand.Domain.Permissions; using Grand.Domain.Seo; @@ -51,6 +53,7 @@ public class Category : BaseEntity, ISlugEntity, IGroupLinkEntity, ITranslationE /// /// Gets or sets the parent category identifier /// + [AuditReference(typeof(Category))] public string ParentCategoryId { get; set; } /// @@ -151,6 +154,7 @@ public virtual ICollection AppliedDiscounts { /// public bool LimitedToGroups { get; set; } + [AuditReference(typeof(CustomerGroup))] public IList CustomerGroups { get; set; } = new List(); /// @@ -163,6 +167,7 @@ public virtual ICollection AppliedDiscounts { /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// diff --git a/src/Core/Grand.Domain/Catalog/Product.cs b/src/Core/Grand.Domain/Catalog/Product.cs index b09f25fbd..0fe2f350f 100644 --- a/src/Core/Grand.Domain/Catalog/Product.cs +++ b/src/Core/Grand.Domain/Catalog/Product.cs @@ -1,3 +1,8 @@ +using Grand.Domain.Vendors; +using Grand.Domain.Tax; +using Grand.Domain.Shipping; +using Grand.Domain.Customers; +using Grand.Domain.Audit; using Grand.Domain.Common; using Grand.Domain.Localization; using Grand.Domain.Permissions; @@ -69,16 +74,19 @@ public class Product : BaseEntity, ITranslationEntity, ISlugEntity, IGroupLinkEn /// /// Gets or sets a value of used product layout identifier /// + [AuditReference(typeof(ProductLayout))] public string ProductLayoutId { get; set; } /// /// Gets or sets a brand identifier /// + [AuditReference(typeof(Brand))] public string BrandId { get; set; } /// /// Gets or sets a vendor identifier /// + [AuditReference(typeof(Vendor))] public string VendorId { get; set; } /// @@ -297,6 +305,7 @@ public class Product : BaseEntity, ITranslationEntity, ISlugEntity, IGroupLinkEn /// /// Gets or sets a delivery date identifier /// + [AuditReference(typeof(DeliveryDate))] public string DeliveryDateId { get; set; } /// @@ -307,6 +316,7 @@ public class Product : BaseEntity, ITranslationEntity, ISlugEntity, IGroupLinkEn /// /// Gets or sets the tax category identifier /// + [AuditReference(typeof(TaxCategory))] public string TaxCategoryId { get; set; } /// @@ -327,6 +337,7 @@ public class Product : BaseEntity, ITranslationEntity, ISlugEntity, IGroupLinkEn /// /// Gets or sets a warehouse identifier /// + [AuditReference(typeof(Warehouse))] public string WarehouseId { get; set; } /// @@ -741,6 +752,7 @@ public virtual ICollection BundleProducts { /// public bool LimitedToGroups { get; set; } + [AuditReference(typeof(CustomerGroup))] public IList CustomerGroups { get; set; } = new List(); /// @@ -753,6 +765,7 @@ public virtual ICollection BundleProducts { /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// diff --git a/src/Core/Grand.Domain/Catalog/ProductCategory.cs b/src/Core/Grand.Domain/Catalog/ProductCategory.cs index c37d08557..acd3b14ba 100644 --- a/src/Core/Grand.Domain/Catalog/ProductCategory.cs +++ b/src/Core/Grand.Domain/Catalog/ProductCategory.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Audit; + namespace Grand.Domain.Catalog; /// @@ -8,6 +10,7 @@ public class ProductCategory : SubBaseEntity /// /// Gets or sets the category identifier /// + [AuditReference(typeof(Category))] public string CategoryId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductCollection.cs b/src/Core/Grand.Domain/Catalog/ProductCollection.cs index c4de7079c..6bdaa9446 100644 --- a/src/Core/Grand.Domain/Catalog/ProductCollection.cs +++ b/src/Core/Grand.Domain/Catalog/ProductCollection.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Audit; + namespace Grand.Domain.Catalog; /// @@ -8,6 +10,7 @@ public class ProductCollection : SubBaseEntity /// /// Gets or sets the collection identifier /// + [AuditReference(typeof(Collection))] public string CollectionId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductSpecificationAttribute.cs b/src/Core/Grand.Domain/Catalog/ProductSpecificationAttribute.cs index 48fd273b6..195e610c4 100644 --- a/src/Core/Grand.Domain/Catalog/ProductSpecificationAttribute.cs +++ b/src/Core/Grand.Domain/Catalog/ProductSpecificationAttribute.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Audit; + namespace Grand.Domain.Catalog; /// @@ -13,11 +15,13 @@ public class ProductSpecificationAttribute : SubBaseEntity /// /// Gets or sets the specification attribute Id /// + [AuditReference(typeof(SpecificationAttribute))] public string SpecificationAttributeId { get; set; } /// /// Gets or sets the specification attribute identifier /// + [AuditReference(typeof(SpecificationAttribute), nameof(SpecificationAttribute.SpecificationAttributeOptions), Owner = nameof(SpecificationAttributeId))] public string SpecificationAttributeOptionId { get; set; } /// diff --git a/src/Core/Grand.Domain/Customers/Customer.cs b/src/Core/Grand.Domain/Customers/Customer.cs index 71899c324..580ea5d04 100644 --- a/src/Core/Grand.Domain/Customers/Customer.cs +++ b/src/Core/Grand.Domain/Customers/Customer.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Vendors; +using Grand.Domain.Stores; using Grand.Domain.Audit; using Grand.Domain.Common; using Grand.Domain.Orders; @@ -78,16 +80,19 @@ public Customer() /// /// Gets or sets the vendor identifier with which this customer is associated (manager) /// + [AuditReference(typeof(Vendor))] public string VendorId { get; set; } /// /// Gets or sets the store identifier /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// /// Gets or sets the staff store identifier /// + [AuditReference(typeof(Store))] public string StaffStoreId { get; set; } /// @@ -196,6 +201,7 @@ public Customer() /// /// Gets or sets the customer groups /// + [AuditReference(typeof(CustomerGroup))] public virtual ICollection Groups { get => _groups ??= new List(); protected set => _groups = value; diff --git a/src/Core/Grand.Domain/Orders/Order.cs b/src/Core/Grand.Domain/Orders/Order.cs index 63dca9821..ac02cbc30 100644 --- a/src/Core/Grand.Domain/Orders/Order.cs +++ b/src/Core/Grand.Domain/Orders/Order.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Stores; +using Grand.Domain.Customers; using Grand.Domain.Audit; using Grand.Domain.Catalog; using Grand.Domain.Common; @@ -37,11 +39,13 @@ public class Order : BaseEntity /// /// Gets or sets the store identifier /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// /// Gets or sets the customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// diff --git a/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs b/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs new file mode 100644 index 000000000..5c898cf46 --- /dev/null +++ b/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs @@ -0,0 +1,131 @@ +using Grand.Data.Audit; +using Grand.Domain.Audit; +using System.Reflection; +using System.Text.Json; + +namespace Grand.Infrastructure.Audit; + +/// +/// Puts names next to the ids of [AuditReference] properties and the numbers of enums when a change is recorded, +/// so the entry stays readable after the referenced record is gone +/// +public class AuditReferences(IAuditRecordReader reader) +{ + private readonly Dictionary<(Type Target, string List, string Owner, string Id), string> _names = new(); + + /// + /// The property that names a record of a type: the one marked [AuditName], else Name + /// + public static string NameProperty(Type type) + { + return type.GetProperties().FirstOrDefault(p => p.IsDefined(typeof(AuditNameAttribute)))?.Name ?? "Name"; + } + + public async Task Describe(Type entityType, IEnumerable changes, IDictionary before, + IDictionary after) + { + foreach (var change in changes.Where(c => !c.Masked)) + { + var property = PropertyAt(entityType, change.Path); + if (property == null) + continue; + + //a name that cannot be found leaves the ids, never the change out + try + { + change.OldValue = await Describe(property, change.Path, change.OldValue, before); + change.NewValue = await Describe(property, change.Path, change.NewValue, after); + } + catch (Exception) + { + //keep the stored values + } + } + } + + //"ProductSpecificationAttributes[id=1].SpecificationAttributeOptionId" -> the property on the element type + private static PropertyInfo PropertyAt(Type type, string path) + { + PropertyInfo property = null; + foreach (var segment in path.Split('.')) + { + property = type?.GetProperty(segment.Split('[')[0]); + if (property == null) + return null; + + type = ElementType(property.PropertyType) ?? property.PropertyType; + } + + return property; + } + + private static Type ElementType(Type type) + { + return type == typeof(string) + ? null + : type.GetInterfaces().Append(type) + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + ?.GetGenericArguments()[0]; + } + + private async Task Describe(PropertyInfo property, string path, string value, + IDictionary snapshot) + { + if (string.IsNullOrEmpty(value)) + return value; + + var reference = property.GetCustomAttribute(); + var enumType = reference?.Target is { IsEnum: true } target + ? target + : Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + if (enumType.IsEnum) + return long.TryParse(value, out var number) && Enum.GetName(enumType, Enum.ToObject(enumType, number)) is { } name + ? $"{name} ({value})" + : value; + + if (reference == null) + return value; + + var owner = reference.Owner == null ? null : SiblingValue(snapshot, path, reference.Owner); + if (ElementType(property.PropertyType) == null) + return await Label(reference, owner, value); + + //a list of ids is stored as one JSON array value + var described = new List(); + foreach (var id in JsonSerializer.Deserialize(value) ?? []) + described.Add(await Label(reference, owner, id)); + return string.Join(", ", described); + } + + private static string SiblingValue(IDictionary snapshot, string path, string sibling) + { + var parent = path.Contains('.') ? path[..(path.LastIndexOf('.') + 1)] : ""; + return snapshot != null && snapshot.TryGetValue(parent + sibling, out var value) ? value : null; + } + + private async Task Label(AuditReferenceAttribute reference, string owner, string id) + { + var key = (reference.Target, reference.List, owner, id); + if (!_names.TryGetValue(key, out var name)) + { + name = await NameOf(reference, owner, id); + _names[key] = name; + } + + return string.IsNullOrEmpty(name) ? id : $"{name} ({id})"; + } + + //the name in the snapshot of the referenced record, or of the element of its list + private async Task NameOf(AuditReferenceAttribute reference, string owner, string id) + { + var recordId = reference.List == null ? id : owner; + if (string.IsNullOrEmpty(recordId)) + return null; + + var record = await reader.Read(reference.Target, recordId); + var path = reference.List == null + ? NameProperty(reference.Target) + : $"{reference.List}[id={id}].{NameProperty(ElementType(reference.Target.GetProperty(reference.List)!.PropertyType)!)}"; + return record != null && record.TryGetValue(path, out var name) && name != "" ? name : null; + } +} diff --git a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs index 91265d7c7..4d75ef13c 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs @@ -12,7 +12,11 @@ namespace Grand.Infrastructure.Audit; /// Scoped per request: snapshots each entity once before its first write, reads the "after" state in batches /// and writes the entries in one insert per batch. Does nothing until activated for a staff request. /// -public class AuditTracker(IAuditEntryWriter writer, IMediator mediator, ILogger logger) +public class AuditTracker( + IAuditEntryWriter writer, + IMediator mediator, + IAuditRecordReader recordReader, + ILogger logger) : IAuditTracker { public const int BatchSize = 200; @@ -180,7 +184,7 @@ public async Task FlushAsync() List entries; try { - entries = await BuildEntries(pending, loaders); + entries = await BuildEntries(pending, loaders, new AuditReferences(recordReader)); entries.AddRange(bulk); if (entries.Count == 0) return; @@ -215,7 +219,8 @@ public async Task FlushAsync() private static async Task> BuildEntries( List> pending, - Dictionary, Task>>>> loaders) + Dictionary, Task>>>> loaders, + AuditReferences references) { var entries = new List(); foreach (var group in pending.GroupBy(p => p.Key.Type)) @@ -232,6 +237,8 @@ private static async Task> BuildEntries( try { entry = Build(group.Key, item.Key.Id, item.Value, after); + if (entry != null) + await references.Describe(group.Key, entry.Changes, item.Value.Before, after); } catch (Exception) { @@ -291,8 +298,7 @@ private static AuditEntry Build(Type type, string entityId, Pending pending, private static string NameOf(Type type, IDictionary snapshot) { - var property = NameProperties.GetOrAdd(type, t => - t.GetProperties().FirstOrDefault(p => p.IsDefined(typeof(AuditNameAttribute)))?.Name ?? "Name"); + var property = NameProperties.GetOrAdd(type, AuditReferences.NameProperty); return snapshot != null && snapshot.TryGetValue(property, out var name) && name != "" ? name : null; } diff --git a/src/Core/Grand.Infrastructure/Startup/StartupApplication.cs b/src/Core/Grand.Infrastructure/Startup/StartupApplication.cs index ed25afb4e..6a070a392 100644 --- a/src/Core/Grand.Infrastructure/Startup/StartupApplication.cs +++ b/src/Core/Grand.Infrastructure/Startup/StartupApplication.cs @@ -82,6 +82,8 @@ private void RegisterDataLayer(IServiceCollection serviceCollection, IConfigurat serviceCollection.AddScoped(typeof(IRepository<>), typeof(AuditingMongoRepository<>)); serviceCollection.AddScoped(sp => new MongoAuditEntryWriter(sp.GetRequiredService)); + serviceCollection.AddScoped(sp => + new MongoAuditRecordReader(sp.GetRequiredService)); } else { @@ -93,6 +95,8 @@ private void RegisterDataLayer(IServiceCollection serviceCollection, IConfigurat serviceCollection.AddScoped(typeof(IRepository<>), typeof(AuditingLiteDBRepository<>)); serviceCollection.AddScoped(sp => new LiteDBAuditEntryWriter(sp.GetRequiredService)); + serviceCollection.AddScoped(sp => + new LiteDBAuditRecordReader(sp.GetRequiredService)); } serviceCollection.AddScoped(); diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs new file mode 100644 index 000000000..b43e483e4 --- /dev/null +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs @@ -0,0 +1,92 @@ +using Grand.Data.Audit; +using Grand.Domain.Audit; +using Grand.Domain.Catalog; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Grand.Infrastructure.Audit; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Infrastructure.Tests.Audit; + +[TestClass] +public class AuditReferencesTests +{ + private readonly Dictionary<(Type, string), IDictionary> _records = new(); + private AuditReferences _references; + + [TestInitialize] + public void Init() + { + var reader = new Mock(); + reader.Setup(r => r.Read(It.IsAny(), It.IsAny())) + .Returns((type, id) => Task.FromResult(_records.GetValueOrDefault((type, id)))); + _references = new AuditReferences(reader.Object); + } + + private async Task Describe(Type type, string path, string oldValue, string newValue, + IDictionary after = null) + { + var change = new AuditChange { Path = path, OldValue = oldValue, NewValue = newValue }; + await _references.Describe(type, [change], after, after); + return change; + } + + [TestMethod] + public async Task Reference_ShowsNameAndId() + { + _records[(typeof(Brand), "b1")] = new Dictionary { ["Name"] = "Aurora" }; + _records[(typeof(Brand), "b2")] = new Dictionary { ["Name"] = "Nordic" }; + + var change = await Describe(typeof(Product), "BrandId", "b1", "b2"); + + Assert.AreEqual("Aurora (b1)", change.OldValue); + Assert.AreEqual("Nordic (b2)", change.NewValue); + } + + [TestMethod] + public async Task ListElement_FoundInTheOwnerNamedByTheSibling() + { + _records[(typeof(SpecificationAttribute), "s1")] = new Dictionary { + ["Name"] = "Color", + ["SpecificationAttributeOptions[id=o1].Name"] = "Red", + ["SpecificationAttributeOptions[id=o2].Name"] = "Blue" + }; + var product = new Dictionary { ["ProductSpecificationAttributes[id=p1].SpecificationAttributeId"] = "s1" }; + + var change = await Describe(typeof(Product), "ProductSpecificationAttributes[id=p1].SpecificationAttributeOptionId", + "o1", "o2", product); + + Assert.AreEqual("Red (o1)", change.OldValue); + Assert.AreEqual("Blue (o2)", change.NewValue); + } + + [TestMethod] + public async Task ListOfIds_EachNamed() + { + _records[(typeof(Store), "s1")] = new Dictionary { ["Name"] = "Main" }; + + var change = await Describe(typeof(Product), "Stores", null, "[\"s1\", \"s9\"]"); + + Assert.AreEqual("Main (s1), s9", change.NewValue); + } + + [TestMethod] + public async Task EnumProperty_ShowsName() + { + var change = await Describe(typeof(Order), "PaymentStatusId", "10", "30"); + + Assert.AreEqual("Pending (10)", change.OldValue); + Assert.AreEqual("Paid (30)", change.NewValue); + } + + [TestMethod] + public async Task UnmarkedOrMissing_KeepsTheStoredValue() + { + var unmarked = await Describe(typeof(Product), "Name", "a", "b"); + var missing = await Describe(typeof(Product), "BrandId", null, "b9"); + + Assert.AreEqual("b", unmarked.NewValue); + Assert.AreEqual("b9", missing.NewValue); + } +} diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs index 809ab9783..a1f3265ea 100644 --- a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs @@ -27,7 +27,8 @@ public void Init() { _writer = new CapturingWriter(); _mediator = new Mock(); - _tracker = new AuditTracker(_writer, _mediator.Object, NullLogger.Instance); + _tracker = new AuditTracker(_writer, _mediator.Object, new Mock().Object, + NullLogger.Instance); _access = ids => { _loadCalls++; diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditingLiteDBRepositoryTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditingLiteDBRepositoryTests.cs index 779ee9654..0a6c70518 100644 --- a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditingLiteDBRepositoryTests.cs +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditingLiteDBRepositoryTests.cs @@ -33,7 +33,8 @@ public void Init() writer.Setup(w => w.InsertManyAsync(It.IsAny>())) .Callback>(e => _written.AddRange(e)) .Returns(Task.CompletedTask); - _tracker = new AuditTracker(writer.Object, new Mock().Object, NullLogger.Instance); + _tracker = new AuditTracker(writer.Object, new Mock().Object, new Mock().Object, + NullLogger.Instance); var auditInfo = new Mock(); auditInfo.Setup(a => a.GetCurrentDateTime()).Returns(DateTime.UtcNow); _repository = new AuditingLiteDBRepository(_database, auditInfo.Object, _tracker); From 928207165c2f6bb0b1c62dba5ec92ff79e6ebc49 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 29 Oct 2026 22:15:15 +0100 Subject: [PATCH 6/8] Search the audit log by staff e-mail or record name 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. --- .../Services/Audit/AuditService.cs | 9 +++++---- .../Common/Audit/AuditEntrySearch.cs | 5 ++++- .../Services/Audit/AuditServiceTests.cs | 17 +++++++++++++++-- .../Controllers/AuditLogControllerTests.cs | 4 ++-- .../Areas/Admin/Views/AuditLog/List.cshtml | 8 ++++---- .../Controllers/AuditLogController.cs | 4 ++-- .../Models/AuditLog/AuditLogListModel.cs | 4 ++-- .../App_Data/Resources/DefaultLanguage.xml | Bin 1629352 -> 1629618 bytes .../App_Data/Resources/Upgrade/en_250.xml | 3 +++ 9 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs b/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs index 088b21529..56edae108 100644 --- a/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs +++ b/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs @@ -41,7 +41,7 @@ public async IAsyncEnumerable StreamEntries(AuditEntrySearch search, var bounded = new AuditEntrySearch { FromUtc = search.FromUtc, ToUtc = search.ToUtc == null || search.ToUtc > now ? now : search.ToUtc, - ActorEmail = search.ActorEmail, + SearchText = search.SearchText, Source = search.Source, Kind = search.Kind, EntityType = search.EntityType, @@ -108,10 +108,11 @@ private IQueryable Query(AuditEntrySearch search) query = query.Where(x => x.CreatedOnUtc >= search.FromUtc.Value); if (search.ToUtc.HasValue) query = query.Where(x => x.CreatedOnUtc <= search.ToUtc.Value); - if (!string.IsNullOrEmpty(search.ActorEmail)) + if (!string.IsNullOrEmpty(search.SearchText)) { - var email = search.ActorEmail.ToLowerInvariant(); - query = query.Where(x => x.ActorEmail != null && x.ActorEmail.ToLower().Contains(email)); + var text = search.SearchText.ToLowerInvariant(); + query = query.Where(x => (x.ActorEmail != null && x.ActorEmail.ToLower().Contains(text)) + || (x.EntityName != null && x.EntityName.ToLower().Contains(text))); } if (search.Source.HasValue) diff --git a/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs b/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs index 0069d0d5c..f95305c5f 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs @@ -9,7 +9,10 @@ public class AuditEntrySearch { public DateTime? FromUtc { get; set; } public DateTime? ToUtc { get; set; } - public string ActorEmail { get; set; } + /// + /// Part of the staff member's e-mail or of the record's name, any case + /// + public string SearchText { get; set; } public AuditSource? Source { get; set; } public AuditKind? Kind { get; set; } public string EntityType { get; set; } diff --git a/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs index 954327d5f..588a25e4d 100644 --- a/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs +++ b/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs @@ -75,16 +75,29 @@ public async Task GetEntries_FiltersByEntityNames() } [TestMethod] - public async Task GetEntries_ActorEmailContainsCaseInsensitive() + public async Task GetEntries_SearchText_StaffEmailCaseInsensitive() { var anna = await Add("Product", "p1", DateTime.UtcNow, "Anna@Shop.com"); await Add("Product", "p1", DateTime.UtcNow, "marek@shop.com"); - var result = await _service.GetEntries(new AuditEntrySearch { ActorEmail = "anna@" }); + var result = await _service.GetEntries(new AuditEntrySearch { SearchText = "anna@" }); Assert.AreEqual(anna.Id, result.Single().Id); } + [TestMethod] + public async Task GetEntries_SearchText_RecordName() + { + var lamp = await Add("Product", "p1", DateTime.UtcNow); + lamp.EntityName = "Aurora Lamp"; + await _repository.UpdateAsync(lamp); + await Add("Product", "p2", DateTime.UtcNow); + + var result = await _service.GetEntries(new AuditEntrySearch { SearchText = "aurora" }); + + Assert.AreEqual(lamp.Id, result.Single().Id); + } + [TestMethod] public async Task GetEntries_DateRange() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs index e09c06349..a3d7525f4 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs @@ -88,11 +88,11 @@ public async Task List_Post_MapsFiltersToSearch() }, 0, 20)); var result = (JsonResult)await _controller.List(new DataSourceRequest { Page = 2, PageSize = 20 }, - new AuditLogListModel { EntityType = "Product", EntityId = "p1", ActorEmail = "anna", Kind = AuditKind.Update }); + new AuditLogListModel { EntityType = "Product", EntityId = "p1", SearchText = "anna", Kind = AuditKind.Update }); Assert.AreEqual("Product", search.EntityType); Assert.AreEqual("p1", search.EntityId); - Assert.AreEqual("anna", search.ActorEmail); + Assert.AreEqual("anna", search.SearchText); Assert.AreEqual(AuditKind.Update, search.Kind); var data = ((DataSourceResult)result.Value!).Data.Cast().Single(); Assert.AreEqual("Price", data.ChangedFields); diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml index 3b0176130..c640f4ada 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml @@ -4,7 +4,7 @@ } @* The list shape without Add new: no primary action, Export CSV as an outline button in the header, - the actor e-mail as the quick search with the other criteria behind the Filters toggle, and the + the staff e-mail or record name as the quick search with the other criteria behind the Filters toggle, and the grid in a flush card. The export posts the same filter form the grid reads. *@ @@ -16,7 +16,7 @@
- +
@@ -62,7 +62,7 @@ GrandAdmin.grids.get('#auditlog-grid').dataSource.page(1); return false; }); - $("#@Html.IdFor(model => model.ActorEmail)").keydown(function (event) { + $("#@Html.IdFor(model => model.SearchText)").keydown(function (event) { if (event.keyCode == 13) { $("#search-auditlog").click(); return false; @@ -74,7 +74,7 @@ var data = { FromUtc: $('#@Html.IdFor(model => model.FromUtc)').val(), ToUtc: $('#@Html.IdFor(model => model.ToUtc)').val(), - ActorEmail: $('#@Html.IdFor(model => model.ActorEmail)').val(), + SearchText: $('#@Html.IdFor(model => model.SearchText)').val(), EntityType: $('#@Html.IdFor(model => model.EntityType)').val(), EntityId: $('#@Html.IdFor(model => model.EntityId)').val(), Kind: $('#@Html.IdFor(model => model.Kind)').val(), diff --git a/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs b/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs index 7263468a5..f0d366d66 100644 --- a/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs @@ -177,7 +177,7 @@ private static AuditEntrySearch ToSearch(AuditLogListModel model) return new AuditEntrySearch { FromUtc = AsUtc(model.FromUtc), ToUtc = AsUtc(model.ToUtc?.Date.AddDays(1).AddTicks(-1)), - ActorEmail = model.ActorEmail?.Trim(), + SearchText = model.SearchText?.Trim(), Source = model.Source, Kind = model.Kind, EntityType = model.EntityType, @@ -274,7 +274,7 @@ void Add(string name, object value) Add("from", model.FromUtc?.ToString("yyyy-MM-dd")); Add("to", model.ToUtc?.ToString("yyyy-MM-dd")); - Add("actor", model.ActorEmail); + Add("search", model.SearchText); Add("source", model.Source); Add("kind", model.Kind); Add("entity type", model.EntityType); diff --git a/src/Web/Grand.Web.Admin/Models/AuditLog/AuditLogListModel.cs b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditLogListModel.cs index 8310cf6d6..6c50bb0db 100644 --- a/src/Web/Grand.Web.Admin/Models/AuditLog/AuditLogListModel.cs +++ b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditLogListModel.cs @@ -16,8 +16,8 @@ public class AuditLogListModel : BaseModel [UIHint("DateNullable")] public DateTime? ToUtc { get; set; } - [GrandResourceDisplayName("Admin.System.AuditLog.Fields.ActorEmail")] - public string ActorEmail { get; set; } + [GrandResourceDisplayName("Admin.System.AuditLog.Fields.SearchText")] + public string SearchText { get; set; } [GrandResourceDisplayName("Admin.System.AuditLog.Fields.Source")] public AuditSource? Source { get; set; } diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 2c6155bd595ef155c0686fc06d2d59e6ae3a9771..d6b2e3692aa62605f993fe527e8b48539189c710 100644 GIT binary patch delta 148 zcmZ3nGkMeQF7M2#)7Pc1l7LFFq7OocV7M>Q~7QQX~%Wh9sSjfaR-5`^f zWjfm;0gLG$>^Rt_Usxl~CZEcn%aF^E$dJj9!=S*B&rk#;ix^TFlELhh>4rNPGp9ee l!LQf8)y)c7D<72VA!!t^fc4 diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml index cf98ef9c9..82a59aa1c 100644 --- a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml @@ -516,6 +516,9 @@ Who + + Staff e-mail or record name + Panel From f91a9b02afc3b56001c3b032e250f86a06e8ac94 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 29 Oct 2026 22:15:17 +0100 Subject: [PATCH 7/8] Name the referenced records across the domain 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. --- .../Grand.Data/Audit/IAuditRecordReader.cs | 5 +- .../LiteDb/LiteDBAuditRecordReader.cs | 8 +- .../Mongo/MongoAuditRecordReader.cs | 6 +- .../Audit/AuditReferenceAttribute.cs | 5 ++ src/Core/Grand.Domain/Blogs/BlogCategory.cs | 4 +- .../Grand.Domain/Blogs/BlogCategoryPost.cs | 5 +- src/Core/Grand.Domain/Blogs/BlogComment.cs | 7 ++ src/Core/Grand.Domain/Blogs/BlogPost.cs | 1 + src/Core/Grand.Domain/Blogs/BlogProduct.cs | 5 +- src/Core/Grand.Domain/Catalog/Bid.cs | 13 +++- src/Core/Grand.Domain/Catalog/Brand.cs | 7 ++ .../Grand.Domain/Catalog/BundleProduct.cs | 3 + src/Core/Grand.Domain/Catalog/Category.cs | 3 + src/Core/Grand.Domain/Catalog/Collection.cs | 7 ++ .../Catalog/OutOfStockSubscription.cs | 8 ++ src/Core/Grand.Domain/Catalog/Product.cs | 9 +++ .../Grand.Domain/Catalog/ProductAttribute.cs | 2 + .../Catalog/ProductAttributeMapping.cs | 2 + .../Catalog/ProductAttributeValue.cs | 2 + .../Catalog/ProductCombinationTierPrices.cs | 6 ++ .../ProductCombinationWarehouseInventory.cs | 4 + src/Core/Grand.Domain/Catalog/ProductPrice.cs | 5 +- .../Catalog/ProductReservation.cs | 7 +- .../Grand.Domain/Catalog/ProductReview.cs | 5 ++ .../Catalog/ProductWarehouseInventory.cs | 4 + .../Catalog/SpecificationAttribute.cs | 2 + src/Core/Grand.Domain/Catalog/TierPrice.cs | 6 ++ src/Core/Grand.Domain/Common/Address.cs | 7 +- .../Grand.Domain/Common/AddressAttribute.cs | 2 + .../Common/AddressAttributeValue.cs | 2 + src/Core/Grand.Domain/Courses/Course.cs | 9 ++- src/Core/Grand.Domain/Courses/CourseAction.cs | 8 +- src/Core/Grand.Domain/Courses/CourseLesson.cs | 6 +- .../Grand.Domain/Courses/CourseSubject.cs | 5 +- src/Core/Grand.Domain/Customers/Customer.cs | 4 + .../Customers/CustomerAttribute.cs | 2 + .../Customers/CustomerAttributeValue.cs | 2 + .../Customers/CustomerGroupProduct.cs | 5 ++ .../Grand.Domain/Customers/CustomerNote.cs | 1 + .../Grand.Domain/Customers/CustomerProduct.cs | 5 +- .../Customers/CustomerProductPrice.cs | 5 +- .../Customers/CustomerTagProduct.cs | 5 ++ .../Customers/ExternalAuthentication.cs | 3 + src/Core/Grand.Domain/Customers/ScimGroup.cs | 7 ++ src/Core/Grand.Domain/Customers/ScimUser.cs | 6 ++ src/Core/Grand.Domain/Directory/Country.cs | 2 + src/Core/Grand.Domain/Directory/Currency.cs | 2 + src/Core/Grand.Domain/Discounts/Discount.cs | 4 +- .../Grand.Domain/Discounts/DiscountCoupon.cs | 1 + .../Discounts/DiscountUsageHistory.cs | 7 ++ src/Core/Grand.Domain/Documents/Document.cs | 9 ++- .../Knowledgebase/KnowledgebaseArticle.cs | 7 +- .../KnowledgebaseArticleComment.cs | 7 +- .../Knowledgebase/KnowledgebaseCategory.cs | 7 +- .../Grand.Domain/Localization/Language.cs | 4 + src/Core/Grand.Domain/Messages/Campaign.cs | 12 ++- .../Grand.Domain/Messages/ContactAttribute.cs | 4 + .../Messages/ContactAttributeValue.cs | 2 + src/Core/Grand.Domain/Messages/ContactUs.cs | 9 ++- .../Grand.Domain/Messages/EmailAccount.cs | 4 +- .../Grand.Domain/Messages/MessageTemplate.cs | 5 +- .../Messages/NewsLetterSubscription.cs | 7 +- .../Messages/NewsletterCategory.cs | 4 +- src/Core/Grand.Domain/News/NewsComment.cs | 7 ++ src/Core/Grand.Domain/News/NewsItem.cs | 3 + .../Grand.Domain/Orders/CheckoutAttribute.cs | 6 ++ .../Orders/CheckoutAttributeValue.cs | 2 + src/Core/Grand.Domain/Orders/GiftVoucher.cs | 2 + .../Orders/GiftVoucherUsageHistory.cs | 4 + .../Orders/LoyaltyPointsHistory.cs | 7 ++ .../Grand.Domain/Orders/MerchandiseReturn.cs | 9 +++ .../Orders/MerchandiseReturnAction.cs | 4 +- .../Orders/MerchandiseReturnReason.cs | 4 +- src/Core/Grand.Domain/Orders/Order.cs | 6 ++ src/Core/Grand.Domain/Orders/OrderApproval.cs | 9 +++ .../Grand.Domain/Orders/OrderApprovalItem.cs | 4 + src/Core/Grand.Domain/Orders/OrderItem.cs | 7 ++ src/Core/Grand.Domain/Orders/OrderNote.cs | 3 + src/Core/Grand.Domain/Pages/Page.cs | 4 + .../Payments/PaymentTransaction.cs | 8 +- .../Grand.Domain/Permissions/Permission.cs | 6 +- .../Permissions/PermissionAction.cs | 4 +- src/Core/Grand.Domain/Seo/EntityUrl.cs | 4 + .../Grand.Domain/Shipping/DeliveryDate.cs | 3 + .../Grand.Domain/Shipping/PickupPoints.cs | 4 + src/Core/Grand.Domain/Shipping/Shipment.cs | 8 ++ .../Grand.Domain/Shipping/ShipmentItem.cs | 4 + .../Grand.Domain/Shipping/ShippingMethod.cs | 3 + src/Core/Grand.Domain/Shipping/Warehouse.cs | 3 + src/Core/Grand.Domain/Stores/Store.cs | 7 ++ src/Core/Grand.Domain/Tasks/ScheduleTask.cs | 6 +- src/Core/Grand.Domain/Tax/TaxCategory.cs | 4 + src/Core/Grand.Domain/Vendors/Vendor.cs | 5 ++ src/Core/Grand.Domain/Vendors/VendorReview.cs | 5 +- .../Audit/AuditReferences.cs | 2 +- .../Audit/AuditReferencesTests.cs | 76 ++++++++++++++++++- 96 files changed, 515 insertions(+), 40 deletions(-) diff --git a/src/Core/Grand.Data/Audit/IAuditRecordReader.cs b/src/Core/Grand.Data/Audit/IAuditRecordReader.cs index 74450b1bb..fda52decf 100644 --- a/src/Core/Grand.Data/Audit/IAuditRecordReader.cs +++ b/src/Core/Grand.Data/Audit/IAuditRecordReader.cs @@ -6,7 +6,8 @@ namespace Grand.Data.Audit; public interface IAuditRecordReader { /// - /// The record of with the id as "path → value", or null when there is none + /// The record of whose (the id when null) is + /// , as "path → value", or null when there is none /// - Task> Read(Type entityType, string id); + Task> Read(Type entityType, string value, string key = null); } diff --git a/src/Core/Grand.Data/LiteDb/LiteDBAuditRecordReader.cs b/src/Core/Grand.Data/LiteDb/LiteDBAuditRecordReader.cs index 0ef429c93..be4a5a0d7 100644 --- a/src/Core/Grand.Data/LiteDb/LiteDBAuditRecordReader.cs +++ b/src/Core/Grand.Data/LiteDb/LiteDBAuditRecordReader.cs @@ -8,10 +8,14 @@ namespace Grand.Data.LiteDb; ///
public class LiteDBAuditRecordReader(Func database) : IAuditRecordReader { - public Task> Read(Type entityType, string id) + public Task> Read(Type entityType, string value, string key = null) { var db = database(); - var document = db.GetCollection(entityType.Name).FindById(new BsonValue(id)); + var collection = db.GetCollection(entityType.Name); + //a key other than the id may hold a number, stored as one + var document = key == null + ? collection.FindById(new BsonValue(value)) + : collection.FindOne(Query.EQ(key, int.TryParse(value, out var number) ? new BsonValue(number) : new BsonValue(value))); //through the entity, so the snapshot has the same shape as on MongoDB var entity = document == null ? null : db.Mapper.ToObject(entityType, document); return Task.FromResult(entity == null ? null : AuditSnapshot.Take(entity, entityType)); diff --git a/src/Core/Grand.Data/Mongo/MongoAuditRecordReader.cs b/src/Core/Grand.Data/Mongo/MongoAuditRecordReader.cs index 01e719bda..812d956b7 100644 --- a/src/Core/Grand.Data/Mongo/MongoAuditRecordReader.cs +++ b/src/Core/Grand.Data/Mongo/MongoAuditRecordReader.cs @@ -10,10 +10,12 @@ namespace Grand.Data.Mongo; ///
public class MongoAuditRecordReader(Func database) : IAuditRecordReader { - public async Task> Read(Type entityType, string id) + public async Task> Read(Type entityType, string value, string key = null) { + //a key other than the id may hold a number, stored as one + BsonValue match = key != null && int.TryParse(value, out var number) ? number : value; var document = await database().GetCollection(entityType.Name) - .Find(new BsonDocument("_id", id)).FirstOrDefaultAsync(); + .Find(new BsonDocument(key ?? "_id", match)).FirstOrDefaultAsync(); return AuditSnapshot.Flatten(document, entityType == typeof(Setting)); } } diff --git a/src/Core/Grand.Domain/Audit/AuditReferenceAttribute.cs b/src/Core/Grand.Domain/Audit/AuditReferenceAttribute.cs index 0d975169f..1e17ee605 100644 --- a/src/Core/Grand.Domain/Audit/AuditReferenceAttribute.cs +++ b/src/Core/Grand.Domain/Audit/AuditReferenceAttribute.cs @@ -17,4 +17,9 @@ public sealed class AuditReferenceAttribute(Type target, string list = null) : A /// The sibling property holding the id of the target record that contains ///
public string Owner { get; set; } + + /// + /// The property of the target that the value matches, when it is not the id (an order status number) + /// + public string Key { get; set; } } diff --git a/src/Core/Grand.Domain/Blogs/BlogCategory.cs b/src/Core/Grand.Domain/Blogs/BlogCategory.cs index aefc7bede..3a81e838a 100644 --- a/src/Core/Grand.Domain/Blogs/BlogCategory.cs +++ b/src/Core/Grand.Domain/Blogs/BlogCategory.cs @@ -1,4 +1,5 @@ -using Grand.Domain.Localization; +using Grand.Domain.Audit; +using Grand.Domain.Localization; using Grand.Domain.Stores; namespace Grand.Domain.Blogs; @@ -27,6 +28,7 @@ public class BlogCategory : BaseEntity, IStoreLinkEntity, ITranslationEntity ///
public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// diff --git a/src/Core/Grand.Domain/Blogs/BlogCategoryPost.cs b/src/Core/Grand.Domain/Blogs/BlogCategoryPost.cs index b0558e932..4becd91a7 100644 --- a/src/Core/Grand.Domain/Blogs/BlogCategoryPost.cs +++ b/src/Core/Grand.Domain/Blogs/BlogCategoryPost.cs @@ -1,6 +1,9 @@ -namespace Grand.Domain.Blogs; +using Grand.Domain.Audit; + +namespace Grand.Domain.Blogs; public class BlogCategoryPost : SubBaseEntity { + [AuditReference(typeof(BlogPost))] public string BlogPostId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Blogs/BlogComment.cs b/src/Core/Grand.Domain/Blogs/BlogComment.cs index d4d71be03..8d29ab01a 100644 --- a/src/Core/Grand.Domain/Blogs/BlogComment.cs +++ b/src/Core/Grand.Domain/Blogs/BlogComment.cs @@ -1,3 +1,7 @@ +using Grand.Domain.Stores; +using Grand.Domain.Customers; +using Grand.Domain.Audit; + namespace Grand.Domain.Blogs; /// @@ -8,11 +12,13 @@ public class BlogComment : BaseEntity /// /// Gets or sets the customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// /// Gets or sets the store identifier /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// @@ -28,5 +34,6 @@ public class BlogComment : BaseEntity /// /// Gets or sets the blog post identifier /// + [AuditReference(typeof(BlogPost))] public string BlogPostId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Blogs/BlogPost.cs b/src/Core/Grand.Domain/Blogs/BlogPost.cs index 5d9e6547a..73d76af21 100644 --- a/src/Core/Grand.Domain/Blogs/BlogPost.cs +++ b/src/Core/Grand.Domain/Blogs/BlogPost.cs @@ -84,6 +84,7 @@ public class BlogPost : BaseEntity, ISlugEntity, IStoreLinkEntity, ITranslationE /// public virtual bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// diff --git a/src/Core/Grand.Domain/Blogs/BlogProduct.cs b/src/Core/Grand.Domain/Blogs/BlogProduct.cs index d31b7000d..a17d93d75 100644 --- a/src/Core/Grand.Domain/Blogs/BlogProduct.cs +++ b/src/Core/Grand.Domain/Blogs/BlogProduct.cs @@ -1,7 +1,10 @@ -namespace Grand.Domain.Blogs; +using Grand.Domain.Audit; + +namespace Grand.Domain.Blogs; public class BlogProduct : BaseEntity { + [AuditReference(typeof(BlogPost))] public string BlogPostId { get; set; } public string ProductId { get; set; } public int DisplayOrder { get; set; } diff --git a/src/Core/Grand.Domain/Catalog/Bid.cs b/src/Core/Grand.Domain/Catalog/Bid.cs index 37fc9ed71..d5954ee9d 100644 --- a/src/Core/Grand.Domain/Catalog/Bid.cs +++ b/src/Core/Grand.Domain/Catalog/Bid.cs @@ -1,4 +1,10 @@ -namespace Grand.Domain.Catalog; +using Grand.Domain.Shipping; +using Grand.Domain.Stores; +using Grand.Domain.Orders; +using Grand.Domain.Customers; +using Grand.Domain.Audit; + +namespace Grand.Domain.Catalog; /// /// Represents a auction bid @@ -8,11 +14,13 @@ public class Bid : BaseEntity /// /// Gets or sets the product identifier /// + [AuditReference(typeof(Product))] public string ProductId { get; set; } /// /// Gets or sets the warehouse identifier /// + [AuditReference(typeof(Warehouse))] public string WarehouseId { get; set; } /// @@ -23,6 +31,7 @@ public class Bid : BaseEntity /// /// Gets or sets the customer id /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// @@ -33,12 +42,14 @@ public class Bid : BaseEntity /// /// Gets or sets Order Id /// + [AuditReference(typeof(Order))] public string OrderId { get; set; } /// /// / /// Gets or sets Store Id /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/Brand.cs b/src/Core/Grand.Domain/Catalog/Brand.cs index 4c34ce098..cfeb56c1d 100644 --- a/src/Core/Grand.Domain/Catalog/Brand.cs +++ b/src/Core/Grand.Domain/Catalog/Brand.cs @@ -1,3 +1,6 @@ +using Grand.Domain.Customers; +using Grand.Domain.Discounts; +using Grand.Domain.Audit; using Grand.Domain.Localization; using Grand.Domain.Permissions; using Grand.Domain.Seo; @@ -30,6 +33,7 @@ public class Brand : BaseEntity, ITranslationEntity, ISlugEntity, IGroupLinkEnti /// /// Gets or sets a value of used brand layout identifier /// + [AuditReference(typeof(BrandLayout))] public string BrandLayoutId { get; set; } /// @@ -105,6 +109,7 @@ public class Brand : BaseEntity, ITranslationEntity, ISlugEntity, IGroupLinkEnti /// /// Gets or sets the collection of applied discounts /// + [AuditReference(typeof(Discount))] public virtual ICollection AppliedDiscounts { get => _appliedDiscounts ??= new List(); protected set => _appliedDiscounts = value; @@ -115,6 +120,7 @@ public virtual ICollection AppliedDiscounts { /// public bool LimitedToGroups { get; set; } + [AuditReference(typeof(CustomerGroup))] public IList CustomerGroups { get; set; } = new List(); /// @@ -127,6 +133,7 @@ public virtual ICollection AppliedDiscounts { /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// diff --git a/src/Core/Grand.Domain/Catalog/BundleProduct.cs b/src/Core/Grand.Domain/Catalog/BundleProduct.cs index 23c7397ca..67bcdb4a8 100644 --- a/src/Core/Grand.Domain/Catalog/BundleProduct.cs +++ b/src/Core/Grand.Domain/Catalog/BundleProduct.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Audit; + namespace Grand.Domain.Catalog; /// @@ -8,6 +10,7 @@ public class BundleProduct : SubBaseEntity /// /// Gets or sets the product identifier /// + [AuditReference(typeof(Product))] public string ProductId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/Category.cs b/src/Core/Grand.Domain/Catalog/Category.cs index a6c8560b4..62dc1d6b0 100644 --- a/src/Core/Grand.Domain/Catalog/Category.cs +++ b/src/Core/Grand.Domain/Catalog/Category.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Discounts; using Grand.Domain.Customers; using Grand.Domain.Audit; using Grand.Domain.Localization; @@ -33,6 +34,7 @@ public class Category : BaseEntity, ISlugEntity, IGroupLinkEntity, ITranslationE /// /// Gets or sets a value of used category layout identifier /// + [AuditReference(typeof(CategoryLayout))] public string CategoryLayoutId { get; set; } /// @@ -144,6 +146,7 @@ public class Category : BaseEntity, ISlugEntity, IGroupLinkEntity, ITranslationE /// /// Gets or sets the collection of applied discounts /// + [AuditReference(typeof(Discount))] public virtual ICollection AppliedDiscounts { get => _appliedDiscounts ??= new List(); protected set => _appliedDiscounts = value; diff --git a/src/Core/Grand.Domain/Catalog/Collection.cs b/src/Core/Grand.Domain/Catalog/Collection.cs index e341d25fd..5cbda39ad 100644 --- a/src/Core/Grand.Domain/Catalog/Collection.cs +++ b/src/Core/Grand.Domain/Catalog/Collection.cs @@ -1,3 +1,6 @@ +using Grand.Domain.Customers; +using Grand.Domain.Discounts; +using Grand.Domain.Audit; using Grand.Domain.Localization; using Grand.Domain.Permissions; using Grand.Domain.Seo; @@ -30,6 +33,7 @@ public class Collection : BaseEntity, ITranslationEntity, ISlugEntity, IGroupLin /// /// Gets or sets a value of used collection layout identifier /// + [AuditReference(typeof(CollectionLayout))] public string CollectionLayoutId { get; set; } /// @@ -110,6 +114,7 @@ public class Collection : BaseEntity, ITranslationEntity, ISlugEntity, IGroupLin /// /// Gets or sets the collection of applied discounts /// + [AuditReference(typeof(Discount))] public virtual ICollection AppliedDiscounts { get => _appliedDiscounts ??= new List(); protected set => _appliedDiscounts = value; @@ -120,6 +125,7 @@ public virtual ICollection AppliedDiscounts { /// public bool LimitedToGroups { get; set; } + [AuditReference(typeof(CustomerGroup))] public IList CustomerGroups { get; set; } = new List(); /// @@ -132,6 +138,7 @@ public virtual ICollection AppliedDiscounts { /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// diff --git a/src/Core/Grand.Domain/Catalog/OutOfStockSubscription.cs b/src/Core/Grand.Domain/Catalog/OutOfStockSubscription.cs index f51106d13..3a347cca8 100644 --- a/src/Core/Grand.Domain/Catalog/OutOfStockSubscription.cs +++ b/src/Core/Grand.Domain/Catalog/OutOfStockSubscription.cs @@ -1,3 +1,7 @@ +using Grand.Domain.Shipping; +using Grand.Domain.Stores; +using Grand.Domain.Customers; +using Grand.Domain.Audit; using Grand.Domain.Common; namespace Grand.Domain.Catalog; @@ -10,11 +14,13 @@ public class OutOfStockSubscription : BaseEntity /// /// Gets or sets the store identifier /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// /// Gets or sets the product identifier /// + [AuditReference(typeof(Product))] public string ProductId { get; set; } /// @@ -30,10 +36,12 @@ public class OutOfStockSubscription : BaseEntity /// /// Gets or sets the warehouse identifier /// + [AuditReference(typeof(Warehouse))] public string WarehouseId { get; set; } /// /// Gets or sets the customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Catalog/Product.cs b/src/Core/Grand.Domain/Catalog/Product.cs index 0fe2f350f..a8a4c0403 100644 --- a/src/Core/Grand.Domain/Catalog/Product.cs +++ b/src/Core/Grand.Domain/Catalog/Product.cs @@ -1,3 +1,6 @@ +using Grand.Domain.Courses; +using Grand.Domain.Directory; +using Grand.Domain.Discounts; using Grand.Domain.Vendors; using Grand.Domain.Tax; using Grand.Domain.Shipping; @@ -42,6 +45,7 @@ public class Product : BaseEntity, ITranslationEntity, ISlugEntity, IGroupLinkEn /// Gets or sets the parent product identifier. It's used to identify associated products (only with "grouped" /// products) /// + [AuditReference(typeof(Product))] public string ParentGroupedProductId { get; set; } /// @@ -484,6 +488,7 @@ public class Product : BaseEntity, ITranslationEntity, ISlugEntity, IGroupLinkEn /// /// Gets or sets a unit of product for PAngV (MeasureWeight entity) /// + [AuditReference(typeof(MeasureWeight))] public string BasepriceUnitId { get; set; } /// @@ -494,16 +499,19 @@ public class Product : BaseEntity, ITranslationEntity, ISlugEntity, IGroupLinkEn /// /// Gets or sets a reference unit for PAngV (MeasureWeight entity) /// + [AuditReference(typeof(MeasureWeight))] public string BasepriceBaseUnitId { get; set; } /// /// Gets or sets a unit of product /// + [AuditReference(typeof(MeasureUnit))] public string UnitId { get; set; } /// /// Gets or sets a course ident /// + [AuditReference(typeof(Course))] public string CourseId { get; set; } /// @@ -708,6 +716,7 @@ public virtual ICollection TierPrices { /// /// Gets or sets the collection of applied discounts /// + [AuditReference(typeof(Discount))] public virtual ICollection AppliedDiscounts { get => _appliedDiscounts ??= new List(); protected set => _appliedDiscounts = value; diff --git a/src/Core/Grand.Domain/Catalog/ProductAttribute.cs b/src/Core/Grand.Domain/Catalog/ProductAttribute.cs index 4c6a0b942..6d012bdab 100644 --- a/src/Core/Grand.Domain/Catalog/ProductAttribute.cs +++ b/src/Core/Grand.Domain/Catalog/ProductAttribute.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Localization; using Grand.Domain.Stores; @@ -31,6 +32,7 @@ public class ProductAttribute : BaseEntity, IStoreLinkEntity, ITranslationEntity /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// diff --git a/src/Core/Grand.Domain/Catalog/ProductAttributeMapping.cs b/src/Core/Grand.Domain/Catalog/ProductAttributeMapping.cs index 44fcce333..1b2e2aea1 100644 --- a/src/Core/Grand.Domain/Catalog/ProductAttributeMapping.cs +++ b/src/Core/Grand.Domain/Catalog/ProductAttributeMapping.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Common; using Grand.Domain.Localization; @@ -13,6 +14,7 @@ public class ProductAttributeMapping : SubBaseEntity, ITranslationEntity /// /// Gets or sets the product attribute identifier /// + [AuditReference(typeof(ProductAttribute))] public string ProductAttributeId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductAttributeValue.cs b/src/Core/Grand.Domain/Catalog/ProductAttributeValue.cs index 8e36dd72a..bff5f0ddb 100644 --- a/src/Core/Grand.Domain/Catalog/ProductAttributeValue.cs +++ b/src/Core/Grand.Domain/Catalog/ProductAttributeValue.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Localization; namespace Grand.Domain.Catalog; @@ -15,6 +16,7 @@ public class ProductAttributeValue : SubBaseEntity, ITranslationEntity /// /// Gets or sets the associated product identifier (used only with AttributeValueType.AssociatedToProduct) /// + [AuditReference(typeof(Product))] public string AssociatedProductId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductCombinationTierPrices.cs b/src/Core/Grand.Domain/Catalog/ProductCombinationTierPrices.cs index 656883795..83fa0ddcc 100644 --- a/src/Core/Grand.Domain/Catalog/ProductCombinationTierPrices.cs +++ b/src/Core/Grand.Domain/Catalog/ProductCombinationTierPrices.cs @@ -1,3 +1,7 @@ +using Grand.Domain.Stores; +using Grand.Domain.Customers; +using Grand.Domain.Audit; + namespace Grand.Domain.Catalog; /// @@ -8,11 +12,13 @@ public class ProductCombinationTierPrices : SubBaseEntity /// /// Gets or sets the store identifier /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// /// Gets or sets the customer group identifier /// + [AuditReference(typeof(CustomerGroup))] public string CustomerGroupId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductCombinationWarehouseInventory.cs b/src/Core/Grand.Domain/Catalog/ProductCombinationWarehouseInventory.cs index d25fe836b..8e341df2c 100644 --- a/src/Core/Grand.Domain/Catalog/ProductCombinationWarehouseInventory.cs +++ b/src/Core/Grand.Domain/Catalog/ProductCombinationWarehouseInventory.cs @@ -1,3 +1,6 @@ +using Grand.Domain.Shipping; +using Grand.Domain.Audit; + namespace Grand.Domain.Catalog; /// @@ -8,6 +11,7 @@ public class ProductCombinationWarehouseInventory : SubBaseEntity /// /// Gets or sets the warehouse identifier /// + [AuditReference(typeof(Warehouse))] public string WarehouseId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductPrice.cs b/src/Core/Grand.Domain/Catalog/ProductPrice.cs index 86fed1ca4..5b8bba819 100644 --- a/src/Core/Grand.Domain/Catalog/ProductPrice.cs +++ b/src/Core/Grand.Domain/Catalog/ProductPrice.cs @@ -1,7 +1,10 @@ -namespace Grand.Domain.Catalog; +using Grand.Domain.Audit; + +namespace Grand.Domain.Catalog; public class ProductPrice : SubBaseEntity { + [AuditReference(typeof(Product))] public string ProductId { get; set; } public string CurrencyCode { get; set; } public double Price { get; set; } diff --git a/src/Core/Grand.Domain/Catalog/ProductReservation.cs b/src/Core/Grand.Domain/Catalog/ProductReservation.cs index c6fe0a203..0bb9602eb 100644 --- a/src/Core/Grand.Domain/Catalog/ProductReservation.cs +++ b/src/Core/Grand.Domain/Catalog/ProductReservation.cs @@ -1,4 +1,7 @@ -namespace Grand.Domain.Catalog; +using Grand.Domain.Orders; +using Grand.Domain.Audit; + +namespace Grand.Domain.Catalog; /// /// Represents a product reservation @@ -8,6 +11,7 @@ public class ProductReservation : BaseEntity /// /// Gets or sets the product identifier /// + [AuditReference(typeof(Product))] public string ProductId { get; set; } /// @@ -18,6 +22,7 @@ public class ProductReservation : BaseEntity /// /// Gets or sets the order id /// + [AuditReference(typeof(Order))] public string OrderId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductReview.cs b/src/Core/Grand.Domain/Catalog/ProductReview.cs index 879b0a050..cf81b9f3f 100644 --- a/src/Core/Grand.Domain/Catalog/ProductReview.cs +++ b/src/Core/Grand.Domain/Catalog/ProductReview.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Stores; +using Grand.Domain.Customers; using Grand.Domain.Audit; namespace Grand.Domain.Catalog; @@ -12,16 +14,19 @@ public class ProductReview : BaseEntity /// /// Gets or sets the customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// /// Gets or sets the product identifier /// + [AuditReference(typeof(Product))] public string ProductId { get; set; } /// /// Gets or sets the store identifier /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductWarehouseInventory.cs b/src/Core/Grand.Domain/Catalog/ProductWarehouseInventory.cs index 42873db5f..206111cca 100644 --- a/src/Core/Grand.Domain/Catalog/ProductWarehouseInventory.cs +++ b/src/Core/Grand.Domain/Catalog/ProductWarehouseInventory.cs @@ -1,3 +1,6 @@ +using Grand.Domain.Shipping; +using Grand.Domain.Audit; + namespace Grand.Domain.Catalog; /// @@ -8,6 +11,7 @@ public class ProductWarehouseInventory : SubBaseEntity /// /// Gets or sets the warehouse identifier /// + [AuditReference(typeof(Warehouse))] public string WarehouseId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/SpecificationAttribute.cs b/src/Core/Grand.Domain/Catalog/SpecificationAttribute.cs index fb5b46e23..e669ab8b7 100644 --- a/src/Core/Grand.Domain/Catalog/SpecificationAttribute.cs +++ b/src/Core/Grand.Domain/Catalog/SpecificationAttribute.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Localization; using Grand.Domain.Stores; @@ -38,6 +39,7 @@ public virtual ICollection SpecificationAttributeO /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// diff --git a/src/Core/Grand.Domain/Catalog/TierPrice.cs b/src/Core/Grand.Domain/Catalog/TierPrice.cs index 4dde02807..32d0cf031 100644 --- a/src/Core/Grand.Domain/Catalog/TierPrice.cs +++ b/src/Core/Grand.Domain/Catalog/TierPrice.cs @@ -1,3 +1,7 @@ +using Grand.Domain.Stores; +using Grand.Domain.Customers; +using Grand.Domain.Audit; + namespace Grand.Domain.Catalog; /// @@ -8,11 +12,13 @@ public class TierPrice : SubBaseEntity /// /// Gets or sets the store identifier (0 - all stores) /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// /// Gets or sets the customer group identifier /// + [AuditReference(typeof(CustomerGroup))] public string CustomerGroupId { get; set; } /// diff --git a/src/Core/Grand.Domain/Common/Address.cs b/src/Core/Grand.Domain/Common/Address.cs index 585288d1f..002ffe392 100644 --- a/src/Core/Grand.Domain/Common/Address.cs +++ b/src/Core/Grand.Domain/Common/Address.cs @@ -1,4 +1,7 @@ -namespace Grand.Domain.Common; +using Grand.Domain.Directory; +using Grand.Domain.Audit; + +namespace Grand.Domain.Common; public class Address : SubBaseEntity { @@ -35,11 +38,13 @@ public class Address : SubBaseEntity /// /// Gets or sets the country identifier /// + [AuditReference(typeof(Country))] public string CountryId { get; set; } /// /// Gets or sets the state/province identifier /// + [AuditReference(typeof(Country), nameof(Country.StateProvinces), Owner = nameof(CountryId))] public string StateProvinceId { get; set; } /// diff --git a/src/Core/Grand.Domain/Common/AddressAttribute.cs b/src/Core/Grand.Domain/Common/AddressAttribute.cs index 3ae14065c..d088b6287 100644 --- a/src/Core/Grand.Domain/Common/AddressAttribute.cs +++ b/src/Core/Grand.Domain/Common/AddressAttribute.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Catalog; using Grand.Domain.Localization; using Grand.Domain.Stores; @@ -58,5 +59,6 @@ public virtual ICollection AddressAttributeValues { /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Common/AddressAttributeValue.cs b/src/Core/Grand.Domain/Common/AddressAttributeValue.cs index 228485eb2..0bc5869b0 100644 --- a/src/Core/Grand.Domain/Common/AddressAttributeValue.cs +++ b/src/Core/Grand.Domain/Common/AddressAttributeValue.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Localization; namespace Grand.Domain.Common; @@ -10,6 +11,7 @@ public class AddressAttributeValue : BaseEntity, ITranslationEntity /// /// Gets or sets the address attribute identifier /// + [AuditReference(typeof(AddressAttribute))] public string AddressAttributeId { get; set; } /// diff --git a/src/Core/Grand.Domain/Courses/Course.cs b/src/Core/Grand.Domain/Courses/Course.cs index 0c3362169..0065c961b 100644 --- a/src/Core/Grand.Domain/Courses/Course.cs +++ b/src/Core/Grand.Domain/Courses/Course.cs @@ -1,4 +1,7 @@ -using Grand.Domain.Localization; +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Domain.Audit; +using Grand.Domain.Localization; using Grand.Domain.Permissions; using Grand.Domain.Seo; using Grand.Domain.Stores; @@ -40,11 +43,13 @@ public class Course : BaseEntity, ITranslationEntity, ISlugEntity, IGroupLinkEnt /// /// Gets or sets the product ident /// + [AuditReference(typeof(Product))] public string ProductId { get; set; } /// /// Gets or sets the level ident /// + [AuditReference(typeof(CourseLevel))] public string LevelId { get; set; } /// @@ -67,6 +72,7 @@ public class Course : BaseEntity, ITranslationEntity, ISlugEntity, IGroupLinkEnt /// public bool LimitedToGroups { get; set; } + [AuditReference(typeof(CustomerGroup))] public IList CustomerGroups { get; set; } = new List(); /// @@ -79,6 +85,7 @@ public class Course : BaseEntity, ITranslationEntity, ISlugEntity, IGroupLinkEnt /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// diff --git a/src/Core/Grand.Domain/Courses/CourseAction.cs b/src/Core/Grand.Domain/Courses/CourseAction.cs index b13ca3709..f746fb131 100644 --- a/src/Core/Grand.Domain/Courses/CourseAction.cs +++ b/src/Core/Grand.Domain/Courses/CourseAction.cs @@ -1,20 +1,26 @@ -namespace Grand.Domain.Courses; +using Grand.Domain.Customers; +using Grand.Domain.Audit; + +namespace Grand.Domain.Courses; public class CourseAction : BaseEntity { /// /// Gets or sets the customer ident /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// /// Gets or sets the course ident /// + [AuditReference(typeof(Course))] public string CourseId { get; set; } /// /// Gets or sets the lesson ident /// + [AuditReference(typeof(CourseLesson))] public string LessonId { get; set; } /// diff --git a/src/Core/Grand.Domain/Courses/CourseLesson.cs b/src/Core/Grand.Domain/Courses/CourseLesson.cs index 17bc14c87..7364808cd 100644 --- a/src/Core/Grand.Domain/Courses/CourseLesson.cs +++ b/src/Core/Grand.Domain/Courses/CourseLesson.cs @@ -1,4 +1,6 @@ -namespace Grand.Domain.Courses; +using Grand.Domain.Audit; + +namespace Grand.Domain.Courses; public class CourseLesson : BaseEntity { @@ -25,11 +27,13 @@ public class CourseLesson : BaseEntity /// /// Gets or sets the course ident /// + [AuditReference(typeof(Course))] public string CourseId { get; set; } /// /// Gets or sets the subject ident /// + [AuditReference(typeof(CourseSubject))] public string SubjectId { get; set; } /// diff --git a/src/Core/Grand.Domain/Courses/CourseSubject.cs b/src/Core/Grand.Domain/Courses/CourseSubject.cs index d1e41744b..0cf710aec 100644 --- a/src/Core/Grand.Domain/Courses/CourseSubject.cs +++ b/src/Core/Grand.Domain/Courses/CourseSubject.cs @@ -1,4 +1,6 @@ -namespace Grand.Domain.Courses; +using Grand.Domain.Audit; + +namespace Grand.Domain.Courses; public class CourseSubject : BaseEntity { @@ -15,5 +17,6 @@ public class CourseSubject : BaseEntity /// /// Gets or sets the course ident /// + [AuditReference(typeof(Course))] public string CourseId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Customers/Customer.cs b/src/Core/Grand.Domain/Customers/Customer.cs index 580ea5d04..c64c76dea 100644 --- a/src/Core/Grand.Domain/Customers/Customer.cs +++ b/src/Core/Grand.Domain/Customers/Customer.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Affiliates; using Grand.Domain.Vendors; using Grand.Domain.Stores; using Grand.Domain.Audit; @@ -75,6 +76,7 @@ public Customer() /// /// Gets or sets the affiliate identifier /// + [AuditReference(typeof(Affiliate))] public string AffiliateId { get; set; } /// @@ -98,6 +100,7 @@ public Customer() /// /// Gets or sets the owner identifier /// + [AuditReference(typeof(Customer))] public string OwnerId { get; set; } /// @@ -241,6 +244,7 @@ public virtual ICollection
Addresses { /// /// Gets or sets the customer tags /// + [AuditReference(typeof(CustomerTag))] public virtual ICollection CustomerTags { get => _customerTags ??= new List(); protected set => _customerTags = value; diff --git a/src/Core/Grand.Domain/Customers/CustomerAttribute.cs b/src/Core/Grand.Domain/Customers/CustomerAttribute.cs index f28802132..8fa2d8742 100644 --- a/src/Core/Grand.Domain/Customers/CustomerAttribute.cs +++ b/src/Core/Grand.Domain/Customers/CustomerAttribute.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Catalog; using Grand.Domain.Localization; using Grand.Domain.Stores; @@ -54,5 +55,6 @@ public virtual ICollection CustomerAttributeValues { ///
public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Customers/CustomerAttributeValue.cs b/src/Core/Grand.Domain/Customers/CustomerAttributeValue.cs index fcbcb0f23..3fa19b840 100644 --- a/src/Core/Grand.Domain/Customers/CustomerAttributeValue.cs +++ b/src/Core/Grand.Domain/Customers/CustomerAttributeValue.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Localization; namespace Grand.Domain.Customers; @@ -10,6 +11,7 @@ public class CustomerAttributeValue : SubBaseEntity, ITranslationEntity /// /// Gets or sets the customer attribute identifier /// + [AuditReference(typeof(CustomerAttribute))] public string CustomerAttributeId { get; set; } /// diff --git a/src/Core/Grand.Domain/Customers/CustomerGroupProduct.cs b/src/Core/Grand.Domain/Customers/CustomerGroupProduct.cs index ebab17b02..9aae08725 100644 --- a/src/Core/Grand.Domain/Customers/CustomerGroupProduct.cs +++ b/src/Core/Grand.Domain/Customers/CustomerGroupProduct.cs @@ -1,3 +1,6 @@ +using Grand.Domain.Catalog; +using Grand.Domain.Audit; + namespace Grand.Domain.Customers; /// @@ -8,11 +11,13 @@ public class CustomerGroupProduct : BaseEntity /// /// Gets or sets the customer group id /// + [AuditReference(typeof(CustomerGroup))] public string CustomerGroupId { get; set; } /// /// Gets or sets the product Id /// + [AuditReference(typeof(Product))] public string ProductId { get; set; } /// diff --git a/src/Core/Grand.Domain/Customers/CustomerNote.cs b/src/Core/Grand.Domain/Customers/CustomerNote.cs index a37d3cbdd..7600141c2 100644 --- a/src/Core/Grand.Domain/Customers/CustomerNote.cs +++ b/src/Core/Grand.Domain/Customers/CustomerNote.cs @@ -10,6 +10,7 @@ public class CustomerNote : BaseEntity /// /// Gets or sets the customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// diff --git a/src/Core/Grand.Domain/Customers/CustomerProduct.cs b/src/Core/Grand.Domain/Customers/CustomerProduct.cs index 6fde63bf8..b6e472061 100644 --- a/src/Core/Grand.Domain/Customers/CustomerProduct.cs +++ b/src/Core/Grand.Domain/Customers/CustomerProduct.cs @@ -1,7 +1,10 @@ -namespace Grand.Domain.Customers; +using Grand.Domain.Audit; + +namespace Grand.Domain.Customers; public class CustomerProduct : BaseEntity { + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } public string ProductId { get; set; } public int DisplayOrder { get; set; } diff --git a/src/Core/Grand.Domain/Customers/CustomerProductPrice.cs b/src/Core/Grand.Domain/Customers/CustomerProductPrice.cs index 751a1b1b0..eade38d40 100644 --- a/src/Core/Grand.Domain/Customers/CustomerProductPrice.cs +++ b/src/Core/Grand.Domain/Customers/CustomerProductPrice.cs @@ -1,7 +1,10 @@ -namespace Grand.Domain.Customers; +using Grand.Domain.Audit; + +namespace Grand.Domain.Customers; public class CustomerProductPrice : BaseEntity { + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } public string ProductId { get; set; } public double Price { get; set; } diff --git a/src/Core/Grand.Domain/Customers/CustomerTagProduct.cs b/src/Core/Grand.Domain/Customers/CustomerTagProduct.cs index 35b5d2e2b..cbd17647a 100644 --- a/src/Core/Grand.Domain/Customers/CustomerTagProduct.cs +++ b/src/Core/Grand.Domain/Customers/CustomerTagProduct.cs @@ -1,3 +1,6 @@ +using Grand.Domain.Catalog; +using Grand.Domain.Audit; + namespace Grand.Domain.Customers; /// @@ -8,11 +11,13 @@ public class CustomerTagProduct : BaseEntity /// /// Gets or sets the customer tag id /// + [AuditReference(typeof(CustomerTag))] public string CustomerTagId { get; set; } /// /// Gets or sets the product Id /// + [AuditReference(typeof(Product))] public string ProductId { get; set; } /// diff --git a/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs b/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs index 82742504f..924c41ba1 100644 --- a/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs +++ b/src/Core/Grand.Domain/Customers/ExternalAuthentication.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Stores; using Grand.Domain.Audit; namespace Grand.Domain.Customers; @@ -10,6 +11,7 @@ public class ExternalAuthentication : BaseEntity /// /// Gets or sets the customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// @@ -41,6 +43,7 @@ public class ExternalAuthentication : BaseEntity /// /// Gets or sets the store of the linked customer when customers are registered per store; empty otherwise /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// diff --git a/src/Core/Grand.Domain/Customers/ScimGroup.cs b/src/Core/Grand.Domain/Customers/ScimGroup.cs index 527d3e3b8..585c21a5f 100644 --- a/src/Core/Grand.Domain/Customers/ScimGroup.cs +++ b/src/Core/Grand.Domain/Customers/ScimGroup.cs @@ -1,3 +1,7 @@ +using Grand.Domain.Vendors; +using Grand.Domain.Stores; +using Grand.Domain.Audit; + namespace Grand.Domain.Customers; /// @@ -15,11 +19,14 @@ public class ScimGroup : BaseEntity public List Members { get; set; } = []; /// Customer group the members get; empty while the group is not mapped + [AuditReference(typeof(CustomerGroup))] public string CustomerGroupId { get; set; } /// Staff store of the members, with the StoreManager group + [AuditReference(typeof(Store))] public string StaffStoreId { get; set; } /// Vendor of the members, with the Vendors group + [AuditReference(typeof(Vendor))] public string VendorId { get; set; } } diff --git a/src/Core/Grand.Domain/Customers/ScimUser.cs b/src/Core/Grand.Domain/Customers/ScimUser.cs index 908cb724a..f3e87e403 100644 --- a/src/Core/Grand.Domain/Customers/ScimUser.cs +++ b/src/Core/Grand.Domain/Customers/ScimUser.cs @@ -1,3 +1,7 @@ +using Grand.Domain.Vendors; +using Grand.Domain.Stores; +using Grand.Domain.Audit; + namespace Grand.Domain.Customers; /// @@ -13,9 +17,11 @@ public class ScimUser : BaseEntity public List GrantedGroupIds { get; set; } = []; /// Staff store SCIM set on the account; empty when SCIM set none + [AuditReference(typeof(Store))] public string GrantedStaffStoreId { get; set; } /// Vendor SCIM set on the account; empty when SCIM set none + [AuditReference(typeof(Vendor))] public string GrantedVendorId { get; set; } /// The account was deactivated by SCIM, so SCIM may activate it again (never an account disabled by hand) diff --git a/src/Core/Grand.Domain/Directory/Country.cs b/src/Core/Grand.Domain/Directory/Country.cs index 9aa57c3df..33dd6ba0b 100644 --- a/src/Core/Grand.Domain/Directory/Country.cs +++ b/src/Core/Grand.Domain/Directory/Country.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Localization; using Grand.Domain.Stores; @@ -68,6 +69,7 @@ public virtual ICollection StateProvinces { /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// diff --git a/src/Core/Grand.Domain/Directory/Currency.cs b/src/Core/Grand.Domain/Directory/Currency.cs index 72fa003d5..0e047f749 100644 --- a/src/Core/Grand.Domain/Directory/Currency.cs +++ b/src/Core/Grand.Domain/Directory/Currency.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Localization; using Grand.Domain.Stores; @@ -63,6 +64,7 @@ public class Currency : BaseEntity, ITranslationEntity, IStoreLinkEntity /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// diff --git a/src/Core/Grand.Domain/Discounts/Discount.cs b/src/Core/Grand.Domain/Discounts/Discount.cs index 47bb33fab..caba77228 100644 --- a/src/Core/Grand.Domain/Discounts/Discount.cs +++ b/src/Core/Grand.Domain/Discounts/Discount.cs @@ -1,4 +1,5 @@ -using Grand.Domain.Stores; +using Grand.Domain.Audit; +using Grand.Domain.Stores; namespace Grand.Domain.Discounts; @@ -115,5 +116,6 @@ public virtual ICollection DiscountRules { /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Discounts/DiscountCoupon.cs b/src/Core/Grand.Domain/Discounts/DiscountCoupon.cs index fa0cdc8c6..fa69a6acf 100644 --- a/src/Core/Grand.Domain/Discounts/DiscountCoupon.cs +++ b/src/Core/Grand.Domain/Discounts/DiscountCoupon.cs @@ -13,6 +13,7 @@ public class DiscountCoupon : BaseEntity /// /// Gets or sets the discount id /// + [AuditReference(typeof(Discount))] public string DiscountId { get; set; } /// diff --git a/src/Core/Grand.Domain/Discounts/DiscountUsageHistory.cs b/src/Core/Grand.Domain/Discounts/DiscountUsageHistory.cs index a0d2e166a..40a2c7911 100644 --- a/src/Core/Grand.Domain/Discounts/DiscountUsageHistory.cs +++ b/src/Core/Grand.Domain/Discounts/DiscountUsageHistory.cs @@ -1,3 +1,7 @@ +using Grand.Domain.Orders; +using Grand.Domain.Customers; +using Grand.Domain.Audit; + namespace Grand.Domain.Discounts; /// @@ -8,6 +12,7 @@ public class DiscountUsageHistory : BaseEntity /// /// Gets or sets the discount identifier /// + [AuditReference(typeof(Discount))] public string DiscountId { get; set; } /// @@ -18,11 +23,13 @@ public class DiscountUsageHistory : BaseEntity /// /// Gets or sets the order identifier /// + [AuditReference(typeof(Order))] public string OrderId { get; set; } /// /// Gets or sets the customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// diff --git a/src/Core/Grand.Domain/Documents/Document.cs b/src/Core/Grand.Domain/Documents/Document.cs index 3580a5cd9..3dab3fb88 100644 --- a/src/Core/Grand.Domain/Documents/Document.cs +++ b/src/Core/Grand.Domain/Documents/Document.cs @@ -1,4 +1,6 @@ -using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Audit; +using Grand.Domain.Common; using Grand.Domain.Permissions; using Grand.Domain.Stores; @@ -32,6 +34,7 @@ public class Document : BaseEntity, IGroupLinkEntity, IStoreLinkEntity /// /// Gets or sets the parent document identifier /// + [AuditReference(typeof(Document))] public string ParentDocumentId { get; set; } /// @@ -67,6 +70,7 @@ public class Document : BaseEntity, IGroupLinkEntity, IStoreLinkEntity /// /// Gets or sets the customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// @@ -87,6 +91,7 @@ public class Document : BaseEntity, IGroupLinkEntity, IStoreLinkEntity /// /// Gets or sets an document type identifier (DocumentType) /// + [AuditReference(typeof(DocumentType))] public string DocumentTypeId { get; set; } /// @@ -134,6 +139,7 @@ public class Document : BaseEntity, IGroupLinkEntity, IStoreLinkEntity /// public bool LimitedToGroups { get; set; } + [AuditReference(typeof(CustomerGroup))] public IList CustomerGroups { get; set; } = new List(); /// @@ -141,5 +147,6 @@ public class Document : BaseEntity, IGroupLinkEntity, IStoreLinkEntity /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseArticle.cs b/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseArticle.cs index d94c6f79e..f8d140a27 100644 --- a/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseArticle.cs +++ b/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseArticle.cs @@ -1,4 +1,6 @@ -using Grand.Domain.Localization; +using Grand.Domain.Customers; +using Grand.Domain.Audit; +using Grand.Domain.Localization; using Grand.Domain.Permissions; using Grand.Domain.Seo; using Grand.Domain.Stores; @@ -58,6 +60,7 @@ public class KnowledgebaseArticle : BaseEntity, ITreeNode, ITranslationEntity, I /// public bool LimitedToGroups { get; set; } + [AuditReference(typeof(CustomerGroup))] public IList CustomerGroups { get; set; } = new List(); /// @@ -70,6 +73,7 @@ public class KnowledgebaseArticle : BaseEntity, ITreeNode, ITranslationEntity, I /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// @@ -85,5 +89,6 @@ public class KnowledgebaseArticle : BaseEntity, ITreeNode, ITranslationEntity, I /// /// Gets or sets parent category Id /// + [AuditReference(typeof(KnowledgebaseCategory))] public string ParentCategoryId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseArticleComment.cs b/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseArticleComment.cs index 2ba337c4c..92352f0d2 100644 --- a/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseArticleComment.cs +++ b/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseArticleComment.cs @@ -1,10 +1,14 @@ -namespace Grand.Domain.Knowledgebase; +using Grand.Domain.Customers; +using Grand.Domain.Audit; + +namespace Grand.Domain.Knowledgebase; public class KnowledgebaseArticleComment : BaseEntity { /// /// Gets or sets the customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// @@ -20,5 +24,6 @@ public class KnowledgebaseArticleComment : BaseEntity /// /// Gets or sets the blog post identifier /// + [AuditReference(typeof(KnowledgebaseArticle))] public string ArticleId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseCategory.cs b/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseCategory.cs index 8f1da6331..83773d68e 100644 --- a/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseCategory.cs +++ b/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseCategory.cs @@ -1,4 +1,6 @@ -using Grand.Domain.Localization; +using Grand.Domain.Customers; +using Grand.Domain.Audit; +using Grand.Domain.Localization; using Grand.Domain.Permissions; using Grand.Domain.Seo; using Grand.Domain.Stores; @@ -43,6 +45,7 @@ public class KnowledgebaseCategory : BaseEntity, ITreeNode, ITranslationEntity, /// public bool LimitedToGroups { get; set; } + [AuditReference(typeof(CustomerGroup))] public IList CustomerGroups { get; set; } = new List(); /// @@ -55,6 +58,7 @@ public class KnowledgebaseCategory : BaseEntity, ITreeNode, ITranslationEntity, /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// @@ -70,5 +74,6 @@ public class KnowledgebaseCategory : BaseEntity, ITreeNode, ITranslationEntity, /// /// Gets or sets parent category Id /// + [AuditReference(typeof(KnowledgebaseCategory))] public string ParentCategoryId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Localization/Language.cs b/src/Core/Grand.Domain/Localization/Language.cs index a9767ca82..e55af1b0b 100644 --- a/src/Core/Grand.Domain/Localization/Language.cs +++ b/src/Core/Grand.Domain/Localization/Language.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Directory; +using Grand.Domain.Audit; using Grand.Domain.Stores; namespace Grand.Domain.Localization; @@ -36,6 +38,7 @@ public class Language : BaseEntity, IStoreLinkEntity /// Gets or sets the identifier of the default currency for this language; 0 is set when we use the default currency /// display order /// + [AuditReference(typeof(Currency))] public string DefaultCurrencyId { get; set; } /// @@ -53,5 +56,6 @@ public class Language : BaseEntity, IStoreLinkEntity /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Messages/Campaign.cs b/src/Core/Grand.Domain/Messages/Campaign.cs index d1f59d4df..64d100d68 100644 --- a/src/Core/Grand.Domain/Messages/Campaign.cs +++ b/src/Core/Grand.Domain/Messages/Campaign.cs @@ -1,4 +1,9 @@ -namespace Grand.Domain.Messages; +using Grand.Domain.Stores; +using Grand.Domain.Localization; +using Grand.Domain.Customers; +using Grand.Domain.Audit; + +namespace Grand.Domain.Messages; /// /// Represents a campaign @@ -27,11 +32,13 @@ public class Campaign : BaseEntity /// /// Gets or sets the store identifier /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// /// Gets or sets the lang identifier /// + [AuditReference(typeof(Language))] public string LanguageId { get; set; } public DateTime? CustomerCreatedDateFrom { get; set; } @@ -48,11 +55,13 @@ public class Campaign : BaseEntity /// /// Gets or sets the used email account identifier /// + [AuditReference(typeof(EmailAccount))] public string EmailAccountId { get; set; } /// /// Gets or sets the customer tags /// + [AuditReference(typeof(CustomerTag))] public virtual ICollection CustomerTags { get => _customerTags ??= new List(); protected set => _customerTags = value; @@ -61,6 +70,7 @@ public virtual ICollection CustomerTags { /// /// Gets or sets the customer groups /// + [AuditReference(typeof(CustomerGroup))] public virtual ICollection CustomerGroups { get => _customerGroups ??= new List(); protected set => _customerGroups = value; diff --git a/src/Core/Grand.Domain/Messages/ContactAttribute.cs b/src/Core/Grand.Domain/Messages/ContactAttribute.cs index 931cf143c..2c5c24767 100644 --- a/src/Core/Grand.Domain/Messages/ContactAttribute.cs +++ b/src/Core/Grand.Domain/Messages/ContactAttribute.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Customers; +using Grand.Domain.Audit; using Grand.Domain.Catalog; using Grand.Domain.Common; using Grand.Domain.Localization; @@ -90,6 +92,7 @@ public virtual ICollection ContactAttributeValues { /// public bool LimitedToGroups { get; set; } + [AuditReference(typeof(CustomerGroup))] public IList CustomerGroups { get; set; } = new List(); /// @@ -97,6 +100,7 @@ public virtual ICollection ContactAttributeValues { /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); diff --git a/src/Core/Grand.Domain/Messages/ContactAttributeValue.cs b/src/Core/Grand.Domain/Messages/ContactAttributeValue.cs index b39fbb49d..a09c30100 100644 --- a/src/Core/Grand.Domain/Messages/ContactAttributeValue.cs +++ b/src/Core/Grand.Domain/Messages/ContactAttributeValue.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Localization; namespace Grand.Domain.Messages; @@ -10,6 +11,7 @@ public class ContactAttributeValue : SubBaseEntity, ITranslationEntity /// /// Gets or sets the checkout attribute mapping identifier /// + [AuditReference(typeof(ContactAttribute))] public string ContactAttributeId { get; set; } /// diff --git a/src/Core/Grand.Domain/Messages/ContactUs.cs b/src/Core/Grand.Domain/Messages/ContactUs.cs index 5a8713c31..2ebf632e0 100644 --- a/src/Core/Grand.Domain/Messages/ContactUs.cs +++ b/src/Core/Grand.Domain/Messages/ContactUs.cs @@ -1,4 +1,7 @@ -using Grand.Domain.Audit; +using Grand.Domain.Vendors; +using Grand.Domain.Stores; +using Grand.Domain.Customers; +using Grand.Domain.Audit; using Grand.Domain.Common; namespace Grand.Domain.Messages; @@ -11,11 +14,13 @@ public class ContactUs : BaseEntity /// /// Gets or sets the customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// /// Gets or sets the store identifier /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// @@ -47,11 +52,13 @@ public class ContactUs : BaseEntity /// /// Gets or sets the email account identifier /// + [AuditReference(typeof(EmailAccount))] public string EmailAccountId { get; set; } /// /// Gets or sets the vendor identifier /// + [AuditReference(typeof(Vendor))] public string VendorId { get; set; } /// diff --git a/src/Core/Grand.Domain/Messages/EmailAccount.cs b/src/Core/Grand.Domain/Messages/EmailAccount.cs index ef756fa67..a6ff52398 100644 --- a/src/Core/Grand.Domain/Messages/EmailAccount.cs +++ b/src/Core/Grand.Domain/Messages/EmailAccount.cs @@ -1,4 +1,5 @@ -using Grand.Domain.Audit; +using Grand.Domain.Stores; +using Grand.Domain.Audit; namespace Grand.Domain.Messages; @@ -53,5 +54,6 @@ public class EmailAccount : BaseEntity /// Gets or sets the store identifier this email account belongs to. /// An empty string means this is a global/shared email account. /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Messages/MessageTemplate.cs b/src/Core/Grand.Domain/Messages/MessageTemplate.cs index 494783c91..0864b110f 100644 --- a/src/Core/Grand.Domain/Messages/MessageTemplate.cs +++ b/src/Core/Grand.Domain/Messages/MessageTemplate.cs @@ -1,4 +1,5 @@ -using Grand.Domain.Localization; +using Grand.Domain.Audit; +using Grand.Domain.Localization; using Grand.Domain.Stores; namespace Grand.Domain.Messages; @@ -51,6 +52,7 @@ public class MessageTemplate : BaseEntity, ITranslationEntity, IStoreLinkEntity /// /// Gets or sets the used email account identifier /// + [AuditReference(typeof(EmailAccount))] public string EmailAccountId { get; set; } /// @@ -58,6 +60,7 @@ public class MessageTemplate : BaseEntity, ITranslationEntity, IStoreLinkEntity /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); diff --git a/src/Core/Grand.Domain/Messages/NewsLetterSubscription.cs b/src/Core/Grand.Domain/Messages/NewsLetterSubscription.cs index 395b054ff..73b56df45 100644 --- a/src/Core/Grand.Domain/Messages/NewsLetterSubscription.cs +++ b/src/Core/Grand.Domain/Messages/NewsLetterSubscription.cs @@ -1,4 +1,6 @@ -using Grand.Domain.Audit; +using Grand.Domain.Stores; +using Grand.Domain.Customers; +using Grand.Domain.Audit; using Grand.Domain.History; namespace Grand.Domain.Messages; @@ -18,6 +20,7 @@ public class NewsLetterSubscription : BaseEntity, IHistory /// /// Gets or sets the Customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// @@ -34,11 +37,13 @@ public class NewsLetterSubscription : BaseEntity, IHistory /// /// Gets or sets the store identifier in which a customer has subscribed to newsletter /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// /// Gets or sets the categories /// + [AuditReference(typeof(NewsletterCategory))] public virtual ICollection Categories { get => _categories ??= new List(); protected set => _categories = value; diff --git a/src/Core/Grand.Domain/Messages/NewsletterCategory.cs b/src/Core/Grand.Domain/Messages/NewsletterCategory.cs index 8278bf457..ba1be5db6 100644 --- a/src/Core/Grand.Domain/Messages/NewsletterCategory.cs +++ b/src/Core/Grand.Domain/Messages/NewsletterCategory.cs @@ -1,4 +1,5 @@ -using Grand.Domain.Localization; +using Grand.Domain.Audit; +using Grand.Domain.Localization; using Grand.Domain.Stores; namespace Grand.Domain.Messages; @@ -27,6 +28,7 @@ public class NewsletterCategory : BaseEntity, ITranslationEntity, IStoreLinkEnti /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); public IList Locales { get; set; } = new List(); diff --git a/src/Core/Grand.Domain/News/NewsComment.cs b/src/Core/Grand.Domain/News/NewsComment.cs index 282b45773..3559c2480 100644 --- a/src/Core/Grand.Domain/News/NewsComment.cs +++ b/src/Core/Grand.Domain/News/NewsComment.cs @@ -1,3 +1,7 @@ +using Grand.Domain.Stores; +using Grand.Domain.Customers; +using Grand.Domain.Audit; + namespace Grand.Domain.News; /// @@ -18,16 +22,19 @@ public class NewsComment : SubBaseEntity /// /// Gets or sets the news item identifier /// + [AuditReference(typeof(NewsItem))] public string NewsItemId { get; set; } /// /// Gets or sets the customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// /// Gets or sets the store identifier /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// diff --git a/src/Core/Grand.Domain/News/NewsItem.cs b/src/Core/Grand.Domain/News/NewsItem.cs index c712b5d29..d7b0a96a1 100644 --- a/src/Core/Grand.Domain/News/NewsItem.cs +++ b/src/Core/Grand.Domain/News/NewsItem.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Customers; using Grand.Domain.Audit; using Grand.Domain.Localization; using Grand.Domain.Permissions; @@ -90,6 +91,7 @@ public virtual ICollection NewsComments { /// public bool LimitedToGroups { get; set; } + [AuditReference(typeof(CustomerGroup))] public IList CustomerGroups { get; set; } = new List(); /// @@ -102,6 +104,7 @@ public virtual ICollection NewsComments { /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// diff --git a/src/Core/Grand.Domain/Orders/CheckoutAttribute.cs b/src/Core/Grand.Domain/Orders/CheckoutAttribute.cs index a601eca13..6e38cbeb6 100644 --- a/src/Core/Grand.Domain/Orders/CheckoutAttribute.cs +++ b/src/Core/Grand.Domain/Orders/CheckoutAttribute.cs @@ -1,3 +1,6 @@ +using Grand.Domain.Tax; +using Grand.Domain.Customers; +using Grand.Domain.Audit; using Grand.Domain.Catalog; using Grand.Domain.Common; using Grand.Domain.Localization; @@ -41,6 +44,7 @@ public class CheckoutAttribute : BaseEntity, ITranslationEntity, IStoreLinkEntit /// /// Gets or sets the tax category identifier /// + [AuditReference(typeof(TaxCategory))] public string TaxCategoryId { get; set; } /// @@ -98,6 +102,7 @@ public virtual ICollection CheckoutAttributeValues { /// public bool LimitedToGroups { get; set; } + [AuditReference(typeof(CustomerGroup))] public IList CustomerGroups { get; set; } = new List(); /// @@ -105,6 +110,7 @@ public virtual ICollection CheckoutAttributeValues { /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); diff --git a/src/Core/Grand.Domain/Orders/CheckoutAttributeValue.cs b/src/Core/Grand.Domain/Orders/CheckoutAttributeValue.cs index eac7bf62f..c28903c23 100644 --- a/src/Core/Grand.Domain/Orders/CheckoutAttributeValue.cs +++ b/src/Core/Grand.Domain/Orders/CheckoutAttributeValue.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Localization; namespace Grand.Domain.Orders; @@ -10,6 +11,7 @@ public class CheckoutAttributeValue : SubBaseEntity, ITranslationEntity /// /// Gets or sets the checkout attribute mapping identifier /// + [AuditReference(typeof(CheckoutAttribute))] public string CheckoutAttributeId { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/GiftVoucher.cs b/src/Core/Grand.Domain/Orders/GiftVoucher.cs index 5fde71434..cdee9f1ea 100644 --- a/src/Core/Grand.Domain/Orders/GiftVoucher.cs +++ b/src/Core/Grand.Domain/Orders/GiftVoucher.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Stores; using Grand.Domain.Audit; using Grand.Domain.Catalog; @@ -69,6 +70,7 @@ public class GiftVoucher : BaseEntity /// /// Gets or sets the store identifier /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/GiftVoucherUsageHistory.cs b/src/Core/Grand.Domain/Orders/GiftVoucherUsageHistory.cs index be296f6ca..e53a3b2de 100644 --- a/src/Core/Grand.Domain/Orders/GiftVoucherUsageHistory.cs +++ b/src/Core/Grand.Domain/Orders/GiftVoucherUsageHistory.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Audit; + namespace Grand.Domain.Orders; /// @@ -8,11 +10,13 @@ public class GiftVoucherUsageHistory : SubBaseEntity /// /// Gets or sets the gift voucher identifier /// + [AuditReference(typeof(GiftVoucher))] public string GiftVoucherId { get; set; } /// /// Gets or sets the order identifier /// + [AuditReference(typeof(Order))] public string UsedWithOrderId { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/LoyaltyPointsHistory.cs b/src/Core/Grand.Domain/Orders/LoyaltyPointsHistory.cs index 9bb0e068b..de39a032b 100644 --- a/src/Core/Grand.Domain/Orders/LoyaltyPointsHistory.cs +++ b/src/Core/Grand.Domain/Orders/LoyaltyPointsHistory.cs @@ -1,3 +1,7 @@ +using Grand.Domain.Stores; +using Grand.Domain.Customers; +using Grand.Domain.Audit; + namespace Grand.Domain.Orders; /// @@ -8,11 +12,13 @@ public class LoyaltyPointsHistory : BaseEntity /// /// Gets or sets the customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// /// Gets or sets the store identifier in which these loyalty points were awarded or redeemed /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// @@ -35,5 +41,6 @@ public class LoyaltyPointsHistory : BaseEntity /// public string Message { get; set; } + [AuditReference(typeof(Order))] public string UsedWithOrderId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Orders/MerchandiseReturn.cs b/src/Core/Grand.Domain/Orders/MerchandiseReturn.cs index 36142f877..534747bc2 100644 --- a/src/Core/Grand.Domain/Orders/MerchandiseReturn.cs +++ b/src/Core/Grand.Domain/Orders/MerchandiseReturn.cs @@ -1,3 +1,7 @@ +using Grand.Domain.Vendors; +using Grand.Domain.Stores; +using Grand.Domain.Customers; +using Grand.Domain.Audit; using Grand.Domain.Common; namespace Grand.Domain.Orders; @@ -17,16 +21,19 @@ public class MerchandiseReturn : BaseEntity /// /// Gets or sets the store identifier /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// /// Gets or sets the order item identifier /// + [AuditReference(typeof(Order))] public string OrderId { get; set; } /// /// Gets or sets the owner item identifier /// + [AuditReference(typeof(Customer))] public string OwnerId { get; set; } /// @@ -42,11 +49,13 @@ public class MerchandiseReturn : BaseEntity /// /// Gets or sets the customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// /// Gets or sets the vendor item identifier /// + [AuditReference(typeof(Vendor))] public string VendorId { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/MerchandiseReturnAction.cs b/src/Core/Grand.Domain/Orders/MerchandiseReturnAction.cs index 935b34e12..f65f4d65d 100644 --- a/src/Core/Grand.Domain/Orders/MerchandiseReturnAction.cs +++ b/src/Core/Grand.Domain/Orders/MerchandiseReturnAction.cs @@ -1,4 +1,5 @@ -using Grand.Domain.Localization; +using Grand.Domain.Audit; +using Grand.Domain.Localization; using Grand.Domain.Stores; namespace Grand.Domain.Orders; @@ -31,5 +32,6 @@ public class MerchandiseReturnAction : BaseEntity, ITranslationEntity, IStoreLin /// /// Gets or sets the stores /// + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Orders/MerchandiseReturnReason.cs b/src/Core/Grand.Domain/Orders/MerchandiseReturnReason.cs index 765c9a081..5d0af06b9 100644 --- a/src/Core/Grand.Domain/Orders/MerchandiseReturnReason.cs +++ b/src/Core/Grand.Domain/Orders/MerchandiseReturnReason.cs @@ -1,4 +1,5 @@ -using Grand.Domain.Localization; +using Grand.Domain.Audit; +using Grand.Domain.Localization; using Grand.Domain.Stores; namespace Grand.Domain.Orders; @@ -31,5 +32,6 @@ public class MerchandiseReturnReason : BaseEntity, ITranslationEntity, IStoreLin /// /// Gets or sets the stores /// + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Orders/Order.cs b/src/Core/Grand.Domain/Orders/Order.cs index ac02cbc30..f4116b0ed 100644 --- a/src/Core/Grand.Domain/Orders/Order.cs +++ b/src/Core/Grand.Domain/Orders/Order.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Localization; +using Grand.Domain.Affiliates; using Grand.Domain.Stores; using Grand.Domain.Customers; using Grand.Domain.Audit; @@ -51,6 +53,7 @@ public class Order : BaseEntity /// /// Gets or sets the owner identifier /// + [AuditReference(typeof(Customer))] public string OwnerId { get; set; } /// @@ -66,6 +69,7 @@ public class Order : BaseEntity /// /// Gets or sets an order status identifier /// + [AuditReference(typeof(OrderStatus), Key = nameof(OrderStatus.StatusId))] public int OrderStatusId { get; set; } /// @@ -232,11 +236,13 @@ public class Order : BaseEntity /// /// Gets or sets the customer language identifier /// + [AuditReference(typeof(Language))] public string CustomerLanguageId { get; set; } /// /// Gets or sets the affiliate identifier /// + [AuditReference(typeof(Affiliate))] public string AffiliateId { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/OrderApproval.cs b/src/Core/Grand.Domain/Orders/OrderApproval.cs index 3d6e050ff..a81747092 100644 --- a/src/Core/Grand.Domain/Orders/OrderApproval.cs +++ b/src/Core/Grand.Domain/Orders/OrderApproval.cs @@ -1,3 +1,7 @@ +using Grand.Domain.Stores; +using Grand.Domain.Customers; +using Grand.Domain.Audit; + namespace Grand.Domain.Orders; /// @@ -8,16 +12,19 @@ public class OrderApproval : BaseEntity /// /// Gets or sets the store identifier the order belongs to /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// /// Gets or sets the identifier of the company owner; every read is scoped by it /// + [AuditReference(typeof(Customer))] public string CompanyOwnerId { get; set; } /// /// Gets or sets the buyer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// @@ -53,6 +60,7 @@ public class OrderApproval : BaseEntity /// /// Gets or sets the identifier of the customer who decided /// + [AuditReference(typeof(Customer))] public string DecidedByCustomerId { get; set; } /// @@ -63,5 +71,6 @@ public class OrderApproval : BaseEntity /// /// Gets or sets the identifier of the order awaiting the decision; set at creation /// + [AuditReference(typeof(Order))] public string OrderId { get; set; } } diff --git a/src/Core/Grand.Domain/Orders/OrderApprovalItem.cs b/src/Core/Grand.Domain/Orders/OrderApprovalItem.cs index 6fd400c6c..ebfe3504e 100644 --- a/src/Core/Grand.Domain/Orders/OrderApprovalItem.cs +++ b/src/Core/Grand.Domain/Orders/OrderApprovalItem.cs @@ -1,3 +1,6 @@ +using Grand.Domain.Catalog; +using Grand.Domain.Audit; + namespace Grand.Domain.Orders; /// @@ -8,6 +11,7 @@ public class OrderApprovalItem : SubBaseEntity /// /// Gets or sets the product identifier /// + [AuditReference(typeof(Product))] public string ProductId { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/OrderItem.cs b/src/Core/Grand.Domain/Orders/OrderItem.cs index d8059b479..3153fadaa 100644 --- a/src/Core/Grand.Domain/Orders/OrderItem.cs +++ b/src/Core/Grand.Domain/Orders/OrderItem.cs @@ -1,3 +1,7 @@ +using Grand.Domain.Shipping; +using Grand.Domain.Vendors; +using Grand.Domain.Catalog; +using Grand.Domain.Audit; using Grand.Domain.Common; namespace Grand.Domain.Orders; @@ -15,6 +19,7 @@ public class OrderItem : SubBaseEntity /// /// Gets or sets the product identifier /// + [AuditReference(typeof(Product))] public string ProductId { get; set; } /// @@ -25,11 +30,13 @@ public class OrderItem : SubBaseEntity /// /// Gets or sets the vendor identifier /// + [AuditReference(typeof(Vendor))] public string VendorId { get; set; } /// /// Gets or sets the warehouse identifier /// + [AuditReference(typeof(Warehouse))] public string WarehouseId { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/OrderNote.cs b/src/Core/Grand.Domain/Orders/OrderNote.cs index fa84a3b4f..5cc69ebac 100644 --- a/src/Core/Grand.Domain/Orders/OrderNote.cs +++ b/src/Core/Grand.Domain/Orders/OrderNote.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Audit; + namespace Grand.Domain.Orders; /// @@ -8,6 +10,7 @@ public class OrderNote : BaseEntity /// /// Gets or sets the order identifier /// + [AuditReference(typeof(Order))] public string OrderId { get; set; } /// diff --git a/src/Core/Grand.Domain/Pages/Page.cs b/src/Core/Grand.Domain/Pages/Page.cs index 7b90cff1c..e27f6c8d4 100644 --- a/src/Core/Grand.Domain/Pages/Page.cs +++ b/src/Core/Grand.Domain/Pages/Page.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Customers; using Grand.Domain.Audit; using Grand.Domain.Localization; using Grand.Domain.Permissions; @@ -75,6 +76,7 @@ public class Page : BaseEntity, ITranslationEntity, ISlugEntity, IStoreLinkEntit /// /// Gets or sets a value of used page layout identifier /// + [AuditReference(typeof(PageLayout))] public string PageLayoutId { get; set; } /// @@ -112,6 +114,7 @@ public class Page : BaseEntity, ITranslationEntity, ISlugEntity, IStoreLinkEntit /// public bool LimitedToGroups { get; set; } + [AuditReference(typeof(CustomerGroup))] public IList CustomerGroups { get; set; } = new List(); @@ -125,6 +128,7 @@ public class Page : BaseEntity, ITranslationEntity, ISlugEntity, IStoreLinkEntit /// public bool LimitedToStores { get; set; } + [AuditReference(typeof(Store))] public IList Stores { get; set; } = new List(); /// diff --git a/src/Core/Grand.Domain/Payments/PaymentTransaction.cs b/src/Core/Grand.Domain/Payments/PaymentTransaction.cs index f3422a48e..d9ba05fa5 100644 --- a/src/Core/Grand.Domain/Payments/PaymentTransaction.cs +++ b/src/Core/Grand.Domain/Payments/PaymentTransaction.cs @@ -1,4 +1,8 @@ -namespace Grand.Domain.Payments; +using Grand.Domain.Stores; +using Grand.Domain.Customers; +using Grand.Domain.Audit; + +namespace Grand.Domain.Payments; public class PaymentTransaction : BaseEntity { @@ -15,6 +19,7 @@ public class PaymentTransaction : BaseEntity /// /// Gets or sets store ident /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// @@ -30,6 +35,7 @@ public class PaymentTransaction : BaseEntity /// /// Gets or sets customer ident /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// diff --git a/src/Core/Grand.Domain/Permissions/Permission.cs b/src/Core/Grand.Domain/Permissions/Permission.cs index 30781a792..0bb413c91 100644 --- a/src/Core/Grand.Domain/Permissions/Permission.cs +++ b/src/Core/Grand.Domain/Permissions/Permission.cs @@ -1,4 +1,7 @@ -namespace Grand.Domain.Permissions; +using Grand.Domain.Customers; +using Grand.Domain.Audit; + +namespace Grand.Domain.Permissions; /// /// Represents a permission @@ -31,6 +34,7 @@ public class Permission : BaseEntity /// /// Gets or sets customer groups /// + [AuditReference(typeof(CustomerGroup))] public virtual ICollection CustomerGroups { get => _customerGroups ??= new List(); protected set => _customerGroups = value; diff --git a/src/Core/Grand.Domain/Permissions/PermissionAction.cs b/src/Core/Grand.Domain/Permissions/PermissionAction.cs index fff65a6ef..db34dc9ab 100644 --- a/src/Core/Grand.Domain/Permissions/PermissionAction.cs +++ b/src/Core/Grand.Domain/Permissions/PermissionAction.cs @@ -1,4 +1,5 @@ -using Grand.Domain.Audit; +using Grand.Domain.Customers; +using Grand.Domain.Audit; namespace Grand.Domain.Permissions; @@ -16,6 +17,7 @@ public class PermissionAction : BaseEntity /// /// Gets or sets the customer group ident /// + [AuditReference(typeof(CustomerGroup))] public string CustomerGroupId { get; set; } /// diff --git a/src/Core/Grand.Domain/Seo/EntityUrl.cs b/src/Core/Grand.Domain/Seo/EntityUrl.cs index 475e16482..4fd2ab2e7 100644 --- a/src/Core/Grand.Domain/Seo/EntityUrl.cs +++ b/src/Core/Grand.Domain/Seo/EntityUrl.cs @@ -1,3 +1,6 @@ +using Grand.Domain.Localization; +using Grand.Domain.Audit; + namespace Grand.Domain.Seo; /// @@ -28,6 +31,7 @@ public class EntityUrl : BaseEntity /// /// Gets or sets the language identifier /// + [AuditReference(typeof(Language))] public string LanguageId { get; set; } /// diff --git a/src/Core/Grand.Domain/Shipping/DeliveryDate.cs b/src/Core/Grand.Domain/Shipping/DeliveryDate.cs index 70a151649..58ad77626 100644 --- a/src/Core/Grand.Domain/Shipping/DeliveryDate.cs +++ b/src/Core/Grand.Domain/Shipping/DeliveryDate.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Stores; +using Grand.Domain.Audit; using Grand.Domain.Localization; namespace Grand.Domain.Shipping; @@ -30,5 +32,6 @@ public class DeliveryDate : BaseEntity, ITranslationEntity /// /// Gets or sets the store identifier. Empty means available to all stores. /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Shipping/PickupPoints.cs b/src/Core/Grand.Domain/Shipping/PickupPoints.cs index 30821f040..fb8758705 100644 --- a/src/Core/Grand.Domain/Shipping/PickupPoints.cs +++ b/src/Core/Grand.Domain/Shipping/PickupPoints.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Stores; +using Grand.Domain.Audit; using Grand.Domain.Common; namespace Grand.Domain.Shipping; @@ -30,11 +32,13 @@ public class PickupPoint : BaseEntity /// /// Gets or sets the warehouse identifier /// + [AuditReference(typeof(Warehouse))] public string WarehouseId { get; set; } /// /// Gets or sets a store identifier /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// diff --git a/src/Core/Grand.Domain/Shipping/Shipment.cs b/src/Core/Grand.Domain/Shipping/Shipment.cs index 974092327..5eb414609 100644 --- a/src/Core/Grand.Domain/Shipping/Shipment.cs +++ b/src/Core/Grand.Domain/Shipping/Shipment.cs @@ -1,3 +1,8 @@ +using Grand.Domain.Vendors; +using Grand.Domain.Stores; +using Grand.Domain.Orders; +using Grand.Domain.Audit; + namespace Grand.Domain.Shipping; /// @@ -10,16 +15,19 @@ public class Shipment : BaseEntity /// /// Gets or sets the order identifier /// + [AuditReference(typeof(Order))] public string OrderId { get; set; } /// /// Gets or sets the store identifier /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// /// Gets or sets the vendor identifier /// + [AuditReference(typeof(Vendor))] public string VendorId { get; set; } /// diff --git a/src/Core/Grand.Domain/Shipping/ShipmentItem.cs b/src/Core/Grand.Domain/Shipping/ShipmentItem.cs index 31db6f8d2..245bfcf2b 100644 --- a/src/Core/Grand.Domain/Shipping/ShipmentItem.cs +++ b/src/Core/Grand.Domain/Shipping/ShipmentItem.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Catalog; +using Grand.Domain.Audit; using Grand.Domain.Common; namespace Grand.Domain.Shipping; @@ -15,6 +17,7 @@ public class ShipmentItem : SubBaseEntity /// /// Gets or sets the product /// + [AuditReference(typeof(Product))] public string ProductId { get; set; } /// @@ -30,5 +33,6 @@ public class ShipmentItem : SubBaseEntity /// /// Gets or sets the warehouse identifier /// + [AuditReference(typeof(Warehouse))] public string WarehouseId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Shipping/ShippingMethod.cs b/src/Core/Grand.Domain/Shipping/ShippingMethod.cs index 060c45f00..a2446dc19 100644 --- a/src/Core/Grand.Domain/Shipping/ShippingMethod.cs +++ b/src/Core/Grand.Domain/Shipping/ShippingMethod.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Stores; +using Grand.Domain.Audit; using Grand.Domain.Directory; using Grand.Domain.Localization; @@ -50,5 +52,6 @@ public virtual ICollection RestrictedGroups { /// /// Gets or sets the store identifier. Empty means available to all stores. /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Shipping/Warehouse.cs b/src/Core/Grand.Domain/Shipping/Warehouse.cs index 0422da638..f9e253a7d 100644 --- a/src/Core/Grand.Domain/Shipping/Warehouse.cs +++ b/src/Core/Grand.Domain/Shipping/Warehouse.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Stores; +using Grand.Domain.Audit; using Grand.Domain.Common; namespace Grand.Domain.Shipping; @@ -45,5 +47,6 @@ public class Warehouse : BaseEntity /// /// Gets or sets the store identifier. Empty means available to all stores. /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Stores/Store.cs b/src/Core/Grand.Domain/Stores/Store.cs index 95b18b003..43389189e 100644 --- a/src/Core/Grand.Domain/Stores/Store.cs +++ b/src/Core/Grand.Domain/Stores/Store.cs @@ -1,3 +1,6 @@ +using Grand.Domain.Shipping; +using Grand.Domain.Directory; +using Grand.Domain.Audit; using Grand.Domain.Localization; namespace Grand.Domain.Stores; @@ -31,21 +34,25 @@ public class Store : BaseEntity, ITranslationEntity /// Gets or sets the identifier of the default language for this store; "" is set when we use the default language /// display order /// + [AuditReference(typeof(Language))] public string DefaultLanguageId { get; set; } /// /// Gets or sets the identifier of the default waregouse for this store /// + [AuditReference(typeof(Warehouse))] public string DefaultWarehouseId { get; set; } /// /// Gets or sets the identifier of the default country for this store /// + [AuditReference(typeof(Country))] public string DefaultCountryId { get; set; } /// /// Gets or sets the identifier of the default currency for this store /// + [AuditReference(typeof(Currency))] public string DefaultCurrencyId { get; set; } /// diff --git a/src/Core/Grand.Domain/Tasks/ScheduleTask.cs b/src/Core/Grand.Domain/Tasks/ScheduleTask.cs index 6d773f6f6..f4355bf3d 100644 --- a/src/Core/Grand.Domain/Tasks/ScheduleTask.cs +++ b/src/Core/Grand.Domain/Tasks/ScheduleTask.cs @@ -1,4 +1,7 @@ -namespace Grand.Domain.Tasks; +using Grand.Domain.Stores; +using Grand.Domain.Audit; + +namespace Grand.Domain.Tasks; public class ScheduleTask : BaseEntity { @@ -12,5 +15,6 @@ public class ScheduleTask : BaseEntity public DateTime? LeasedUntilUtc { get; set; } public string LeasedByInstance { get; set; } public int TimeInterval { get; set; } + [AuditReference(typeof(Store))] public string StoreId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Tax/TaxCategory.cs b/src/Core/Grand.Domain/Tax/TaxCategory.cs index f6236219c..33425c170 100644 --- a/src/Core/Grand.Domain/Tax/TaxCategory.cs +++ b/src/Core/Grand.Domain/Tax/TaxCategory.cs @@ -1,3 +1,6 @@ +using Grand.Domain.Stores; +using Grand.Domain.Audit; + namespace Grand.Domain.Tax; /// @@ -18,5 +21,6 @@ public class TaxCategory : BaseEntity /// /// Gets or sets the store identifier (empty = available to all stores) /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Vendors/Vendor.cs b/src/Core/Grand.Domain/Vendors/Vendor.cs index 43f1765fb..ce8a6f7c0 100644 --- a/src/Core/Grand.Domain/Vendors/Vendor.cs +++ b/src/Core/Grand.Domain/Vendors/Vendor.cs @@ -1,3 +1,6 @@ +using Grand.Domain.Stores; +using Grand.Domain.Discounts; +using Grand.Domain.Audit; using Grand.Domain.Common; using Grand.Domain.Localization; using Grand.Domain.Seo; @@ -41,6 +44,7 @@ public Vendor() /// /// Gets or sets the store identifier /// + [AuditReference(typeof(Store))] public string StoreId { get; set; } /// @@ -145,6 +149,7 @@ public virtual ICollection VendorNotes { /// /// Gets or sets the collection of applied discounts /// + [AuditReference(typeof(Discount))] public virtual ICollection AppliedDiscounts { get => _appliedDiscounts ??= new List(); protected set => _appliedDiscounts = value; diff --git a/src/Core/Grand.Domain/Vendors/VendorReview.cs b/src/Core/Grand.Domain/Vendors/VendorReview.cs index e96cc838f..351dbb5cb 100644 --- a/src/Core/Grand.Domain/Vendors/VendorReview.cs +++ b/src/Core/Grand.Domain/Vendors/VendorReview.cs @@ -1,4 +1,5 @@ -using Grand.Domain.Audit; +using Grand.Domain.Customers; +using Grand.Domain.Audit; namespace Grand.Domain.Vendors; @@ -12,11 +13,13 @@ public class VendorReview : BaseEntity /// /// Gets or sets the customer identifier /// + [AuditReference(typeof(Customer))] public string CustomerId { get; set; } /// /// Gets or sets the vendor identifier /// + [AuditReference(typeof(Vendor))] public string VendorId { get; set; } /// diff --git a/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs b/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs index 5c898cf46..2e2d694cb 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs @@ -122,7 +122,7 @@ private async Task NameOf(AuditReferenceAttribute reference, string owne if (string.IsNullOrEmpty(recordId)) return null; - var record = await reader.Read(reference.Target, recordId); + var record = await reader.Read(reference.Target, recordId, reference.List == null ? reference.Key : null); var path = reference.List == null ? NameProperty(reference.Target) : $"{reference.List}[id={id}].{NameProperty(ElementType(reference.Target.GetProperty(reference.List)!.PropertyType)!)}"; diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs index b43e483e4..5aa4f79be 100644 --- a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs @@ -19,8 +19,9 @@ public class AuditReferencesTests public void Init() { var reader = new Mock(); - reader.Setup(r => r.Read(It.IsAny(), It.IsAny())) - .Returns((type, id) => Task.FromResult(_records.GetValueOrDefault((type, id)))); + reader.Setup(r => r.Read(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((type, value, key) => + Task.FromResult(_records.GetValueOrDefault((type, key == null ? value : $"{key}={value}")))); _references = new AuditReferences(reader.Object); } @@ -61,6 +62,21 @@ public async Task ListElement_FoundInTheOwnerNamedByTheSibling() Assert.AreEqual("Blue (o2)", change.NewValue); } + [TestMethod] + public async Task AddressState_FoundInItsCountry() + { + _records[(typeof(Grand.Domain.Directory.Country), "pl")] = new Dictionary { + ["Name"] = "Poland", ["StateProvinces[id=st1].Name"] = "Mazowieckie" + }; + var order = new Dictionary { ["BillingAddress.CountryId"] = "pl" }; + + var country = await Describe(typeof(Order), "BillingAddress.CountryId", null, "pl", order); + var state = await Describe(typeof(Order), "BillingAddress.StateProvinceId", null, "st1", order); + + Assert.AreEqual("Poland (pl)", country.NewValue); + Assert.AreEqual("Mazowieckie (st1)", state.NewValue); + } + [TestMethod] public async Task ListOfIds_EachNamed() { @@ -71,6 +87,18 @@ public async Task ListOfIds_EachNamed() Assert.AreEqual("Main (s1), s9", change.NewValue); } + [TestMethod] + public async Task ReferenceByKey_MatchesThatProperty() + { + _records[(typeof(OrderStatus), "StatusId=10")] = new Dictionary { ["Name"] = "Pending" }; + _records[(typeof(OrderStatus), "StatusId=40")] = new Dictionary { ["Name"] = "Cancelled" }; + + var change = await Describe(typeof(Order), "OrderStatusId", "10", "40"); + + Assert.AreEqual("Pending (10)", change.OldValue); + Assert.AreEqual("Cancelled (40)", change.NewValue); + } + [TestMethod] public async Task EnumProperty_ShowsName() { @@ -80,6 +108,50 @@ public async Task EnumProperty_ShowsName() Assert.AreEqual("Paid (30)", change.NewValue); } + //a wrong mark shows only ids with no sign of the mistake, so every mark of the domain is checked here + [TestMethod] + public void EveryReferenceMark_PointsToANamedRecord() + { + var problems = new List(); + foreach (var type in typeof(Grand.Domain.BaseEntity).Assembly.GetTypes()) + 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"); + } + + Assert.AreEqual(0, problems.Count, string.Join(Environment.NewLine, problems)); + } + [TestMethod] public async Task UnmarkedOrMissing_KeepsTheStoredValue() { From 01090c575250f5815c915f44f45a001982baa591 Mon Sep 17 00:00:00 2001 From: support Date: Thu, 29 Oct 2026 22:35:41 +0100 Subject: [PATCH 8/8] Fix order-note and reference-naming review findings - 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 --- .../Grand.Data/Audit/IAuditRecordReader.cs | 5 +- .../LiteDb/LiteDBAuditRecordReader.cs | 4 +- .../Mongo/MongoAuditRecordReader.cs | 9 ++-- .../Grand.Infrastructure/Audit/AuditDiff.cs | 5 +- .../Audit/AuditReferences.cs | 35 ++++++++++--- .../Audit/AuditTracker.cs | 6 +-- .../Audit/AuditReferencesTests.cs | 50 ++++++++++++++++--- .../BaseOrderManagementControllerTests.cs | 4 +- .../Audit/AuditEventRecorderTests.cs | 22 ++++++++ .../Areas/Admin/Views/AuditLog/List.cshtml | 3 +- .../BaseOrderManagementController.cs | 8 +-- .../Audit/AuditEventRecorder.cs | 12 ++--- .../Audit/IAuditEventRecorder.cs | 4 +- 13 files changed, 126 insertions(+), 41 deletions(-) diff --git a/src/Core/Grand.Data/Audit/IAuditRecordReader.cs b/src/Core/Grand.Data/Audit/IAuditRecordReader.cs index fda52decf..9b73bb20d 100644 --- a/src/Core/Grand.Data/Audit/IAuditRecordReader.cs +++ b/src/Core/Grand.Data/Audit/IAuditRecordReader.cs @@ -7,7 +7,8 @@ public interface IAuditRecordReader { /// /// The record of whose (the id when null) is - /// , as "path → value", or null when there is none + /// , as "path → value", or null when there is none. With , + /// the snapshot may hold only that top-level field. /// - Task> Read(Type entityType, string value, string key = null); + Task> Read(Type entityType, string value, string key = null, string field = null); } diff --git a/src/Core/Grand.Data/LiteDb/LiteDBAuditRecordReader.cs b/src/Core/Grand.Data/LiteDb/LiteDBAuditRecordReader.cs index be4a5a0d7..6227f59c7 100644 --- a/src/Core/Grand.Data/LiteDb/LiteDBAuditRecordReader.cs +++ b/src/Core/Grand.Data/LiteDb/LiteDBAuditRecordReader.cs @@ -8,7 +8,9 @@ namespace Grand.Data.LiteDb; /// public class LiteDBAuditRecordReader(Func database) : IAuditRecordReader { - public Task> Read(Type entityType, string value, string key = null) + //the embedded database reads the whole document either way, so the field is not projected + public Task> Read(Type entityType, string value, string key = null, + string field = null) { var db = database(); var collection = db.GetCollection(entityType.Name); diff --git a/src/Core/Grand.Data/Mongo/MongoAuditRecordReader.cs b/src/Core/Grand.Data/Mongo/MongoAuditRecordReader.cs index 812d956b7..8e5580609 100644 --- a/src/Core/Grand.Data/Mongo/MongoAuditRecordReader.cs +++ b/src/Core/Grand.Data/Mongo/MongoAuditRecordReader.cs @@ -10,12 +10,15 @@ namespace Grand.Data.Mongo; /// public class MongoAuditRecordReader(Func database) : IAuditRecordReader { - public async Task> Read(Type entityType, string value, string key = null) + public async Task> Read(Type entityType, string value, string key = null, + string field = null) { //a key other than the id may hold a number, stored as one BsonValue match = key != null && int.TryParse(value, out var number) ? number : value; - var document = await database().GetCollection(entityType.Name) - .Find(new BsonDocument(key ?? "_id", match)).FirstOrDefaultAsync(); + var find = database().GetCollection(entityType.Name).Find(new BsonDocument(key ?? "_id", match)); + var document = field == null + ? await find.FirstOrDefaultAsync() + : await find.Project(Builders.Projection.Include(field)).FirstOrDefaultAsync(); return AuditSnapshot.Flatten(document, entityType == typeof(Setting)); } } diff --git a/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs b/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs index e8d1b1a70..dd87f6a21 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditDiff.cs @@ -64,7 +64,10 @@ private static bool IsUnder(string path, string parent) || path.StartsWith(parent + "[", StringComparison.Ordinal); } - private static AuditChange Change(string path, string oldValue, string newValue) + /// + /// A change of one path, its values cut to and marked when they were + /// + public static AuditChange Change(string path, string oldValue, string newValue) { var change = new AuditChange { Path = path, diff --git a/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs b/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs index 2e2d694cb..b0ef9b605 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs @@ -1,6 +1,8 @@ using Grand.Data.Audit; using Grand.Domain.Audit; +using System.Collections.Concurrent; using System.Reflection; +using System.Text.RegularExpressions; using System.Text.Json; namespace Grand.Infrastructure.Audit; @@ -9,22 +11,30 @@ namespace Grand.Infrastructure.Audit; /// Puts names next to the ids of [AuditReference] properties and the numbers of enums when a change is recorded, /// so the entry stays readable after the referenced record is gone /// -public class AuditReferences(IAuditRecordReader reader) +public partial class AuditReferences(IAuditRecordReader reader) { + private static readonly ConcurrentDictionary NameProperties = new(); + private static readonly ConcurrentDictionary<(Type, string), PropertyInfo> Properties = new(); + private readonly Dictionary<(Type Target, string List, string Owner, string Id), string> _names = new(); + [GeneratedRegex(@"\[[^\]]*\]")] + private static partial Regex ElementKeys(); + /// /// The property that names a record of a type: the one marked [AuditName], else Name /// public static string NameProperty(Type type) { - return type.GetProperties().FirstOrDefault(p => p.IsDefined(typeof(AuditNameAttribute)))?.Name ?? "Name"; + return NameProperties.GetOrAdd(type, t => + t.GetProperties().FirstOrDefault(p => p.IsDefined(typeof(AuditNameAttribute)))?.Name ?? "Name"); } public async Task Describe(Type entityType, IEnumerable changes, IDictionary before, IDictionary after) { - foreach (var change in changes.Where(c => !c.Masked)) + //a cut value is no longer a whole id list; it stays as stored + foreach (var change in changes.Where(c => !c.Masked && !c.Truncated)) { var property = PropertyAt(entityType, change.Path); if (property == null) @@ -33,8 +43,12 @@ public async Task Describe(Type entityType, IEnumerable changes, ID //a name that cannot be found leaves the ids, never the change out try { - change.OldValue = await Describe(property, change.Path, change.OldValue, before); - change.NewValue = await Describe(property, change.Path, change.NewValue, after); + var described = AuditDiff.Change(change.Path, + await Describe(property, change.Path, change.OldValue, before), + await Describe(property, change.Path, change.NewValue, after)); + change.OldValue = described.OldValue; + change.NewValue = described.NewValue; + change.Truncated = described.Truncated; } catch (Exception) { @@ -45,6 +59,12 @@ public async Task Describe(Type entityType, IEnumerable changes, ID //"ProductSpecificationAttributes[id=1].SpecificationAttributeOptionId" -> the property on the element type private static PropertyInfo PropertyAt(Type type, string path) + { + //cached by the path without its element keys, so the cache grows with the model, not with the data + return Properties.GetOrAdd((type, ElementKeys().Replace(path, "")), key => FindProperty(key.Item1, key.Item2)); + } + + private static PropertyInfo FindProperty(Type type, string path) { PropertyInfo property = null; foreach (var segment in path.Split('.')) @@ -122,7 +142,10 @@ private async Task NameOf(AuditReferenceAttribute reference, string owne if (string.IsNullOrEmpty(recordId)) return null; - var record = await reader.Read(reference.Target, recordId, reference.List == null ? reference.Key : null); + //only the field holding the name is read, not the whole record + var record = reference.List == null + ? await reader.Read(reference.Target, recordId, reference.Key, NameProperty(reference.Target)) + : await reader.Read(reference.Target, recordId, null, reference.List); var path = reference.List == null ? NameProperty(reference.Target) : $"{reference.List}[id={id}].{NameProperty(ElementType(reference.Target.GetProperty(reference.List)!.PropertyType)!)}"; diff --git a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs index 4d75ef13c..8ff0970b9 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs @@ -3,7 +3,6 @@ using Grand.Domain.Audit; using Grand.Mediator; using Microsoft.Extensions.Logging; -using System.Collections.Concurrent; using System.Reflection; namespace Grand.Infrastructure.Audit; @@ -31,9 +30,6 @@ public class AuditTracker( "LastActivityDateUtc", "LastIpAddress", "LastVisitedPage" ]; - //the property that names a record of each type: [AuditName], else Name - private static readonly ConcurrentDictionary NameProperties = new(); - private readonly List _bulk = []; private readonly Dictionary, Task>>>> @@ -298,7 +294,7 @@ private static AuditEntry Build(Type type, string entityId, Pending pending, private static string NameOf(Type type, IDictionary snapshot) { - var property = NameProperties.GetOrAdd(type, AuditReferences.NameProperty); + var property = AuditReferences.NameProperty(type); return snapshot != null && snapshot.TryGetValue(property, out var name) && name != "" ? name : null; } diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs index 5aa4f79be..f77fee341 100644 --- a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs @@ -13,22 +13,23 @@ namespace Grand.Infrastructure.Tests.Audit; public class AuditReferencesTests { private readonly Dictionary<(Type, string), IDictionary> _records = new(); + private Mock _reader; private AuditReferences _references; [TestInitialize] public void Init() { - var reader = new Mock(); - reader.Setup(r => r.Read(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns((type, value, key) => + _reader = new Mock(); + _reader.Setup(r => r.Read(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((type, value, key, _) => Task.FromResult(_records.GetValueOrDefault((type, key == null ? value : $"{key}={value}")))); - _references = new AuditReferences(reader.Object); + _references = new AuditReferences(_reader.Object); } private async Task Describe(Type type, string path, string oldValue, string newValue, - IDictionary after = null) + IDictionary after = null, bool truncated = false) { - var change = new AuditChange { Path = path, OldValue = oldValue, NewValue = newValue }; + var change = new AuditChange { Path = path, OldValue = oldValue, NewValue = newValue, Truncated = truncated }; await _references.Describe(type, [change], after, after); return change; } @@ -152,6 +153,43 @@ public void EveryReferenceMark_PointsToANamedRecord() Assert.AreEqual(0, problems.Count, string.Join(Environment.NewLine, problems)); } + [TestMethod] + public async Task Reference_ReadsOnlyTheNameField() + { + _records[(typeof(Brand), "b1")] = new Dictionary { ["Name"] = "Aurora" }; + + await Describe(typeof(Product), "BrandId", null, "b1"); + + _reader.Verify(r => r.Read(typeof(Brand), "b1", null, "Name"), Times.Once); + } + + [TestMethod] + public async Task TruncatedValue_KeepsTheStoredValue() + { + _records[(typeof(Store), "s1")] = new Dictionary { ["Name"] = "Main" }; + + var change = await Describe(typeof(Product), "Stores", null, "[\"s1\", \"s", truncated: true); + + Assert.AreEqual("[\"s1\", \"s", change.NewValue); + Assert.IsTrue(change.Truncated); + _reader.Verify(r => r.Read(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task LongNames_CutToTheLimitAndMarked() + { + var ids = Enumerable.Range(0, 40).Select(i => $"s{i}").ToList(); + foreach (var id in ids) + _records[(typeof(Store), id)] = new Dictionary { ["Name"] = new string('n', 40) }; + + var change = await Describe(typeof(Product), "Stores", null, + "[" + string.Join(", ", ids.Select(id => $"\"{id}\"")) + "]"); + + Assert.AreEqual(AuditDiff.MaxValueLength, change.NewValue.Length); + Assert.IsTrue(change.Truncated); + } + [TestMethod] public async Task UnmarkedOrMissing_KeepsTheStoredValue() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs index 208d8694c..5f872aa42 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs @@ -448,7 +448,7 @@ public async Task AddProductToOrderSearch_ScopeDenies_ReturnsGridError() [TestMethod] public async Task OrderNoteAdd_RecordedOnTheOrder() { - var order = new Order { Id = "o1", OrderNumber = 7 }; + var order = new Order { Id = "o1", OrderNumber = 7, StoreId = "s1" }; _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); _scopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(true); _orderNoteViewModelServiceMock.Setup(v => v.InsertOrderNote(order, null, false, "msg")).ReturnsAsync(true); @@ -456,7 +456,7 @@ public async Task OrderNoteAdd_RecordedOnTheOrder() await _controller.OrderNoteAdd("o1", null, false, "msg", recorder.Object); - recorder.Verify(r => r.OwnedChange("Order", "o1", "7", "OrderNote", null, "msg"), Times.Once); + recorder.Verify(r => r.OwnedChange("Order", "o1", "7", "s1", "OrderNote", null, "msg"), Times.Once); } [TestMethod] diff --git a/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs b/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs index 6c9219c4d..9ff923824 100644 --- a/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs @@ -94,6 +94,28 @@ public async Task AccessDenied_RecordsPermissionAndAction() Assert.AreEqual("permission: ManagePlugins / Edit", entry.Details); } + [TestMethod] + public async Task OwnedChange_ScopedToTheOwnersStore() + { + await _recorder.OwnedChange("Order", "o1", "7", "s2", "OrderNote", null, "msg"); + + var entry = _recorded.Single(); + Assert.AreEqual(AuditKind.Update, entry.Kind); + Assert.AreEqual("o1", entry.EntityId); + Assert.AreEqual("s2", entry.EntityStoreId); + Assert.AreEqual("msg", entry.Changes.Single().NewValue); + } + + [TestMethod] + public async Task OwnedChange_LongNote_CutAndMarked() + { + await _recorder.OwnedChange("Order", "o1", "7", "s2", "OrderNote", null, new string('x', 3000)); + + var change = _recorded.Single().Changes.Single(); + Assert.AreEqual(AuditDiff.MaxValueLength, change.NewValue.Length); + Assert.IsTrue(change.Truncated); + } + [TestMethod] public async Task Plugin_RecordsNameAndChange() { diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml index c640f4ada..18681fa93 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/List.cshtml @@ -35,6 +35,7 @@ + {{ CreatedOn }} @@ -48,7 +49,7 @@ - @Loc["Admin.Common.View"] + {{ $texts.view }} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs index ba3e6d31d..76e35b801 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs @@ -673,8 +673,8 @@ public async Task OrderNoteAdd(string orderId, string downloadId, }); //a note is a record of its own; this puts it in the order's change history - await auditEventRecorder.OwnedChange(nameof(Order), order.Id, order.OrderNumber.ToString(), "OrderNote", null, - message); + await auditEventRecorder.OwnedChange(nameof(Order), order.Id, order.OrderNumber.ToString(), order.StoreId, + "OrderNote", null, message); return Json(new { Result = true }); } @@ -689,8 +689,8 @@ public async Task OrderNoteDelete(string id, string orderId, var note = (await OrderService.GetOrderNotes(order.Id)).FirstOrDefault(n => n.Id == id); await orderNoteViewModelService.DeleteOrderNote(order, id); - await auditEventRecorder.OwnedChange(nameof(Order), order.Id, order.OrderNumber.ToString(), "OrderNote", - note?.Note, null); + await auditEventRecorder.OwnedChange(nameof(Order), order.Id, order.OrderNumber.ToString(), order.StoreId, + "OrderNote", note?.Note, null); return new JsonResult(""); } diff --git a/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs b/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs index 33812291d..5658d16f5 100644 --- a/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs +++ b/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs @@ -89,23 +89,19 @@ public Task Plugin(string pluginName, string change) }); } - public Task OwnedChange(string entityType, string entityId, string entityName, string path, string oldValue, - string newValue) + public Task OwnedChange(string entityType, string entityId, string entityName, string entityStoreId, string path, + string oldValue, string newValue) { return Record(AuditKind.Update, CurrentStaff(), entry => { entry.EntityType = entityType; entry.EntityId = entityId; entry.EntityName = entityName; - entry.Changes = [new AuditChange { Path = path, OldValue = Shorten(oldValue), NewValue = Shorten(newValue) }]; + entry.EntityStoreId = entityStoreId; + entry.Changes = [AuditDiff.Change(path, oldValue, newValue)]; }); } - private static string Shorten(string value) - { - return value is { Length: > AuditDiff.MaxValueLength } ? value[..AuditDiff.MaxValueLength] : value; - } - private async Task RecordSignInEvent(AuditKind kind, Customer customer, string reason) { try diff --git a/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs b/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs index ca947816b..20af841f0 100644 --- a/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs +++ b/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs @@ -45,6 +45,6 @@ public interface IAuditEventRecorder /// Records, on a record, a change made through a separate record that belongs to it (a note of an order), so it /// shows in the owner's change history /// - Task OwnedChange(string entityType, string entityId, string entityName, string path, string oldValue, - string newValue); + Task OwnedChange(string entityType, string entityId, string entityName, string entityStoreId, string path, + string oldValue, string newValue); }