notification module

This commit is contained in:
Dhananjaya99
2026-08-21 12:05:47 +05:30
parent 9b8c2493ad
commit d413ad4064
41 changed files with 16868 additions and 228 deletions
@@ -0,0 +1,12 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Notifications;
using ERPCore.Dtos.Notifications;
namespace ERPCore.Services.Interfaces;
public interface INotificationConfigService
{
Task<ETagged<NotificationConfigDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<NotificationConfigDto>> UpdateAsync(UpdateNotificationConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task<IReadOnlyList<NotificationChannel>> ResolveEnabledChannelsAsync(string moduleKey, CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
using ERPCore.Domain.Notifications;
namespace ERPCore.Services.Interfaces;
public interface INotificationModuleCatalog
{
IReadOnlyList<NotificationModuleDefinition> GetAll();
NotificationModuleDefinition? Find(string moduleKey);
}
+49 -5
View File
@@ -1,6 +1,7 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
using ERPCore.Infra.UoW;
@@ -108,9 +109,11 @@ public sealed class ItemService : IItemService
if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
var config = (await _config.GetAsync(ct)).Value;
await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
request.BaseUomId, request.DefaultVendorId, config, ct);
ValidateFeatureConfiguration(request, config);
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
@@ -163,9 +166,11 @@ public sealed class ItemService : IItemService
&& await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct))
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
var config = (await _config.GetAsync(ct)).Value;
await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, ct);
request.BaseUomId, request.DefaultVendorId, config, ct);
ValidateFeatureConfiguration(request, config);
// The base UOM is the sole meaning of every quantity recorded against this item —
// stock layers, ledger rows and document lines are all plain counts of it. Once any
@@ -280,10 +285,9 @@ public sealed class ItemService : IItemService
/// it is advisory and honoured by the frontend only (docs/11 §2.8).
/// </summary>
private async Task ValidateReferencesAsync(
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId, CancellationToken ct)
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId,
ProductConfigDto config, CancellationToken ct)
{
var config = (await _config.GetAsync(ct)).Value;
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
@@ -337,6 +341,46 @@ public sealed class ItemService : IItemService
}
}
private static void ValidateFeatureConfiguration(CreateItemRequest request, ProductConfigDto config)
=> ValidateFeatureConfiguration(
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
request.TrackingMode, request.Warranty, config);
private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config)
=> ValidateFeatureConfiguration(
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
request.TrackingMode, request.Warranty, config);
private static void ValidateFeatureConfiguration(
decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice,
TrackingMode trackingMode, Warranty warranty, ProductConfigDto config)
{
if (!config.SellingPricesEnabled &&
(sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null))
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Selling prices are disabled in the product configuration; sellingMinPrice, sellingMaxPrice and sellingFlatPrice must be null.",
422);
if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Serial numbers are disabled in the product configuration; trackingMode cannot be Serial.",
422);
if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Batch lists are disabled in the product configuration; trackingMode cannot be Batch.",
422);
if (!config.WarrantyPeriodsEnabled && warranty == Warranty.Warranty)
throw new DomainException(
ErrorCodes.ConfigDisabled,
"Warranty periods are disabled in the product configuration; warranty cannot be Warranty.",
422);
}
/// <summary>
/// A warranty-tracked item must declare a coverage period (one of
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
@@ -0,0 +1,143 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Notifications;
using ERPCore.Dtos.Notifications;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class NotificationConfigService : INotificationConfigService
{
private readonly IRepository<NotificationConfig> _config;
private readonly IRepository<NotificationConfigModule> _modules;
private readonly INotificationModuleCatalog _catalog;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public NotificationConfigService(
IRepository<NotificationConfig> config,
IRepository<NotificationConfigModule> modules,
INotificationModuleCatalog catalog,
ICurrentUser currentUser,
IUnitOfWork uow)
{
_config = config;
_modules = modules;
_catalog = catalog;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<NotificationConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.Include(c => c.Modules)
.FirstOrDefaultAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)
?? throw new NotFoundException("Notification configuration has not been seeded.");
return new ETagged<NotificationConfigDto>(Map(config), config.RowVersion);
}
public async Task<ETagged<NotificationConfigDto>> UpdateAsync(UpdateNotificationConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.Query()
.Include(c => c.Modules)
.FirstOrDefaultAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)
?? throw new NotFoundException("Notification configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The notification configuration was modified by another request.", 412);
var catalog = _catalog.GetAll();
var inputs = request.Modules ?? new List<UpdateNotificationModuleRequest>();
if (inputs.Any(x => string.IsNullOrWhiteSpace(x.ModuleKey)))
throw new DomainException(ErrorCodes.Validation, "Notification module keys cannot be empty.", 400);
if (inputs.Select(x => x.ModuleKey.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Count() != inputs.Count)
throw new DomainException(ErrorCodes.Validation, "Duplicate notification module keys are not allowed.", 400);
var byKey = inputs.ToDictionary(x => x.ModuleKey.Trim(), StringComparer.OrdinalIgnoreCase);
var unknown = byKey.Keys.Where(k => catalog.All(m => !string.Equals(m.Key, k, StringComparison.OrdinalIgnoreCase))).ToList();
if (unknown.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Unknown notification module key(s): {string.Join(", ", unknown)}.", 422);
var missing = catalog.Where(m => !byKey.ContainsKey(m.Key)).Select(m => m.Key).ToList();
if (missing.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Missing notification module settings for: {string.Join(", ", missing)}.", 422);
config.Modules.Clear();
foreach (var module in catalog)
{
var input = byKey[module.Key];
config.Modules.Add(new NotificationConfigModule
{
NotificationConfigId = config.NotificationConfigId,
ModuleKey = module.Key,
EmailEnabled = input.EmailEnabled!.Value,
SmsEnabled = input.SmsEnabled!.Value,
InAppEnabled = input.InAppEnabled!.Value
});
}
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
await SaveGuardingConcurrencyAsync(ct);
return new ETagged<NotificationConfigDto>(Map(config), config.RowVersion);
}
public async Task<IReadOnlyList<NotificationChannel>> ResolveEnabledChannelsAsync(string moduleKey, CancellationToken ct = default)
{
var key = moduleKey?.Trim();
if (string.IsNullOrWhiteSpace(key)) return Array.Empty<NotificationChannel>();
var row = await _modules.Query().AsNoTracking()
.FirstOrDefaultAsync(m => m.ModuleKey == key, ct);
if (row is null) return Array.Empty<NotificationChannel>();
var channels = new List<NotificationChannel>(3);
if (row.EmailEnabled) channels.Add(NotificationChannel.Email);
if (row.SmsEnabled) channels.Add(NotificationChannel.Sms);
if (row.InAppEnabled) channels.Add(NotificationChannel.InApp);
return channels;
}
private async Task SaveGuardingConcurrencyAsync(CancellationToken ct)
{
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The notification configuration was modified by another request.", 412);
}
}
private NotificationConfigDto Map(NotificationConfig config)
{
var rows = config.Modules
.ToDictionary(m => m.ModuleKey, m => m, StringComparer.OrdinalIgnoreCase);
var modules = _catalog.GetAll()
.Select(m =>
{
rows.TryGetValue(m.Key, out var row);
return new NotificationModuleConfigDto(
m.Key,
m.Label,
m.Description,
row?.EmailEnabled ?? false,
row?.SmsEnabled ?? false,
row?.InAppEnabled ?? false);
})
.ToList();
return new NotificationConfigDto(modules, config.UpdatedAt, config.UpdatedBy);
}
}
@@ -0,0 +1,35 @@
using System.Reflection;
using ERPCore.Domain.Notifications;
using ERPCore.Domain.Notifications.Modules;
using ERPCore.Services.Interfaces;
namespace ERPCore.Services;
public sealed class NotificationModuleCatalog : INotificationModuleCatalog
{
private readonly IReadOnlyList<NotificationModuleDefinition> _modules;
public NotificationModuleCatalog()
{
var moduleType = typeof(SaleCreateNotificationModule).Assembly;
_modules = moduleType.GetTypes()
.Select(t => new { Type = t, Attr = t.GetCustomAttribute<NotificationModuleAttribute>() })
.Where(x => x.Attr is not null)
.Select(x => new NotificationModuleDefinition(x.Attr!.Key, x.Attr.Label, x.Attr.Description, x.Attr.SortOrder))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Key, StringComparer.Ordinal)
.ToList();
var duplicates = _modules.GroupBy(m => m.Key, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
if (duplicates.Count > 0)
throw new InvalidOperationException($"Duplicate notification module key(s) found: {string.Join(", ", duplicates)}.");
}
public IReadOnlyList<NotificationModuleDefinition> GetAll() => _modules;
public NotificationModuleDefinition? Find(string moduleKey)
=> _modules.FirstOrDefault(m => string.Equals(m.Key, moduleKey, StringComparison.OrdinalIgnoreCase));
}
@@ -12,7 +12,7 @@ namespace ERPCore.Services;
/// <summary>
/// Product configuration service (FR-MD-11) over the singleton row seeded by DataSeeder.
/// Reads are never gated — only writes consult the flags — so switching a feature off
/// Reads are never gated - only writes consult the flags - so switching a feature off
/// leaves existing data readable (docs/11-BACKEND-PHASE1.md §2.8).
/// </summary>
public sealed class ProductConfigService : IProductConfigService
@@ -46,11 +46,15 @@ public sealed class ProductConfigService : IProductConfigService
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
// Non-null by [Required] on the nullable bools — an omitted flag is a 400, so the
// Non-null by [Required] on the nullable bools - an omitted flag is a 400, so the
// model binder has already rejected anything that would reach here with a null.
config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value;
config.BrandsEnabled = request.BrandsEnabled!.Value;
config.ItemTypesEnabled = request.ItemTypesEnabled!.Value;
config.SellingPricesEnabled = request.SellingPricesEnabled!.Value;
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -67,5 +71,7 @@ public sealed class ProductConfigService : IProductConfigService
}
private static ProductConfigDto Map(ProductConfig c) => new(
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled, c.UpdatedAt, c.UpdatedBy);
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled,
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
c.UpdatedAt, c.UpdatedBy);
}