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,34 @@
using ERPCore.Dtos.Notifications;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/notification-config")]
public sealed class NotificationConfigController : ApiControllerBase
{
private readonly INotificationConfigService _config;
public NotificationConfigController(INotificationConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<NotificationConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<NotificationConfigDto>> Update([FromBody] UpdateNotificationConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -0,0 +1,19 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Notification configuration (new settings module) - singleton root row.
/// </summary>
public class NotificationConfig
{
public const int SingletonId = 1;
public int NotificationConfigId { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
public uint RowVersion { get; set; }
public ICollection<NotificationConfigModule> Modules { get; set; } = new List<NotificationConfigModule>();
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Entities;
public class NotificationConfigModule
{
public int NotificationConfigModuleId { get; set; }
public int NotificationConfigId { get; set; }
public NotificationConfig? NotificationConfig { get; set; }
public string ModuleKey { get; set; } = string.Empty;
public bool EmailEnabled { get; set; }
public bool SmsEnabled { get; set; }
public bool InAppEnabled { get; set; }
}
@@ -1,22 +1,24 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Product configuration (FR-MD-11) — a <b>singleton row</b> (single-tenant, docs/00-CORE §1)
/// Product configuration (FR-MD-11) - a singleton row (single-tenant, docs/00-CORE §1)
/// gating optional product master-data features.
/// <para>
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is
/// <b>advisory only</b> — items carry no item-type reference (see <see cref="ItemType"/>),
/// so there is nothing on a write to reject; the frontend honours it by hiding the
/// builder's type section. Reads are never gated, so existing data stays visible after a
/// flag is switched off.
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is advisory only
/// - items carry no item-type reference (see <see cref="ItemType"/>), so there is nothing
/// on a write to reject; the frontend honours it by hiding the variant builder.
/// <see cref="SellingPricesEnabled"/>, <see cref="SerialNumbersEnabled"/>,
/// <see cref="BatchListsEnabled"/> and <see cref="WarrantyPeriodsEnabled"/> are enforced
/// on item writes and drive the create/edit screens. Reads are never gated, so existing
/// data stays visible after a flag is switched off.
/// </para>
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class ProductConfig
{
/// <summary>Always 1 — the singleton row's id.</summary>
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
@@ -24,6 +26,10 @@ public class ProductConfig
public bool SubcategoriesEnabled { get; set; } = true;
public bool BrandsEnabled { get; set; } = true;
public bool ItemTypesEnabled { get; set; } = true;
public bool SellingPricesEnabled { get; set; } = true;
public bool SerialNumbersEnabled { get; set; } = true;
public bool BatchListsEnabled { get; set; } = true;
public bool WarrantyPeriodsEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
@@ -0,0 +1,34 @@
namespace ERPCore.Domain.Notifications.Modules;
[NotificationModule("sale.create", "Sale Created", "Raised when a sales document is created.", 10)]
public sealed class SaleCreateNotificationModule;
[NotificationModule("sale.posted", "Sale Posted", "Raised when a sales document is posted.", 20)]
public sealed class SalePostedNotificationModule;
[NotificationModule("grn.created", "GRN Created", "Raised when a goods receipt note is created.", 30)]
public sealed class GrnCreatedNotificationModule;
[NotificationModule("grn.confirmed", "GRN Confirmed", "Raised when a goods receipt note is confirmed.", 40)]
public sealed class GrnConfirmedNotificationModule;
[NotificationModule("purchase.order.created", "Purchase Order Created", "Raised when a purchase order is created.", 50)]
public sealed class PurchaseOrderCreatedNotificationModule;
[NotificationModule("purchase.order.approved", "Purchase Order Approved", "Raised when a purchase order is approved.", 60)]
public sealed class PurchaseOrderApprovedNotificationModule;
[NotificationModule("purchase.return.created", "Purchase Return Created", "Raised when a purchase return is created.", 70)]
public sealed class PurchaseReturnCreatedNotificationModule;
[NotificationModule("stock.adjustment.created", "Stock Adjustment Created", "Raised when a stock adjustment is created.", 80)]
public sealed class StockAdjustmentCreatedNotificationModule;
[NotificationModule("stock.transfer.created", "Stock Transfer Created", "Raised when a stock transfer is created.", 90)]
public sealed class StockTransferCreatedNotificationModule;
[NotificationModule("item.created", "Item Created", "Raised when an item master record is created.", 100)]
public sealed class ItemCreatedNotificationModule;
[NotificationModule("item.updated", "Item Updated", "Raised when an item master record is updated.", 110)]
public sealed class ItemUpdatedNotificationModule;
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Notifications;
public enum NotificationChannel
{
Email,
Sms,
InApp
}
@@ -0,0 +1,18 @@
namespace ERPCore.Domain.Notifications;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class NotificationModuleAttribute : Attribute
{
public string Key { get; }
public string Label { get; }
public string Description { get; }
public int SortOrder { get; }
public NotificationModuleAttribute(string key, string label, string description, int sortOrder)
{
Key = key;
Label = label;
Description = description;
SortOrder = sortOrder;
}
}
@@ -0,0 +1,7 @@
namespace ERPCore.Domain.Notifications;
public sealed record NotificationModuleDefinition(
string Key,
string Label,
string Description,
int SortOrder);
@@ -4,10 +4,14 @@ namespace ERPCore.Dtos.Config;
/// <summary>
/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton.
/// <see cref="ItemTypesEnabled"/> is advisory (frontend-honoured) — see the entity docs.
/// <see cref="ItemTypesEnabled"/> is advisory (frontend-honoured) - see the entity docs.
/// <see cref="SellingPricesEnabled"/>, <see cref="SerialNumbersEnabled"/>,
/// <see cref="BatchListsEnabled"/> and <see cref="WarrantyPeriodsEnabled"/> are enforced
/// when items are written.
/// </summary>
public sealed record ProductConfigDto(
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
@@ -15,8 +19,8 @@ public sealed record ProductConfigDto(
/// <para>
/// The flags are <see cref="bool"/>? deliberately: <c>[Required]</c> on a non-nullable bool
/// is a no-op (it always has a value), so a body of <c>{}</c> would bind every flag to
/// <c>false</c> and silently switch all three features off. Nullable makes the requirement
/// actually bind — an omitted flag is a 400, not an accidental disable.
/// <c>false</c> and silently switch everything off. Nullable makes the requirement actually
/// bind - an omitted flag is a 400, not an accidental disable.
/// </para>
/// </summary>
public sealed class UpdateProductConfigRequest
@@ -24,4 +28,8 @@ public sealed class UpdateProductConfigRequest
[Required] public bool? SubcategoriesEnabled { get; set; }
[Required] public bool? BrandsEnabled { get; set; }
[Required] public bool? ItemTypesEnabled { get; set; }
[Required] public bool? SellingPricesEnabled { get; set; }
[Required] public bool? SerialNumbersEnabled { get; set; }
[Required] public bool? BatchListsEnabled { get; set; }
[Required] public bool? WarrantyPeriodsEnabled { get; set; }
}
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Notifications;
public sealed record NotificationModuleConfigDto(
string ModuleKey,
string Label,
string Description,
bool EmailEnabled,
bool SmsEnabled,
bool InAppEnabled);
public sealed record NotificationConfigDto(
IReadOnlyList<NotificationModuleConfigDto> Modules,
DateTime? UpdatedAt,
int? UpdatedBy);
public sealed class UpdateNotificationModuleRequest
{
[Required, StringLength(100)] public string ModuleKey { get; set; } = string.Empty;
[Required] public bool? EmailEnabled { get; set; }
[Required] public bool? SmsEnabled { get; set; }
[Required] public bool? InAppEnabled { get; set; }
}
public sealed class UpdateNotificationConfigRequest
{
[Required, MinLength(0)] public List<UpdateNotificationModuleRequest> Modules { get; set; } = new();
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class NotificationConfigConfiguration : IEntityTypeConfiguration<NotificationConfig>
{
public void Configure(EntityTypeBuilder<NotificationConfig> builder)
{
builder.ToTable("notification_config", t =>
t.HasCheckConstraint("ck_notification_config_singleton", $"\"NotificationConfigId\" = {NotificationConfig.SingletonId}"));
builder.HasKey(c => c.NotificationConfigId);
builder.Property(c => c.NotificationConfigId).ValueGeneratedNever();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasMany(c => c.Modules)
.WithOne(m => m.NotificationConfig)
.HasForeignKey(m => m.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,21 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class NotificationConfigModuleConfiguration : IEntityTypeConfiguration<NotificationConfigModule>
{
public void Configure(EntityTypeBuilder<NotificationConfigModule> builder)
{
builder.ToTable("notification_config_modules");
builder.HasKey(m => m.NotificationConfigModuleId);
builder.Property(m => m.ModuleKey).IsRequired().HasMaxLength(100);
builder.HasIndex(m => m.ModuleKey).IsUnique();
builder.Property(m => m.EmailEnabled).IsRequired().HasDefaultValue(false);
builder.Property(m => m.SmsEnabled).IsRequired().HasDefaultValue(false);
builder.Property(m => m.InAppEnabled).IsRequired().HasDefaultValue(false);
}
}
@@ -25,6 +25,10 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<Produc
builder.Property(c => c.SubcategoriesEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.BrandsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.ItemTypesEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.SellingPricesEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.SerialNumbersEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.BatchListsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.WarrantyPeriodsEnabled).IsRequired().HasDefaultValue(true);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
@@ -33,6 +33,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-sales", Label = "Bundle Sales", Href = "/dashboard/sales/bundles", SortOrder = 1 },
// Procurement (NavItemId 4) children — mirror the hub page order.
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
@@ -43,8 +43,9 @@ public static class DataSeeder
{
var dirty = await SeedReasonCodesAsync(db, ct);
dirty |= await SeedItemTypesAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
dirty |= await SeedSalesAsync(db, ct);
@@ -100,7 +101,22 @@ public static class DataSeeder
ConfigId = ProductConfig.SingletonId,
SubcategoriesEnabled = true,
BrandsEnabled = true,
ItemTypesEnabled = true
ItemTypesEnabled = true,
SellingPricesEnabled = true,
SerialNumbersEnabled = true,
BatchListsEnabled = true,
WarrantyPeriodsEnabled = true
});
return true;
}
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
db.NotificationConfig.Add(new NotificationConfig
{
NotificationConfigId = NotificationConfig.SingletonId
});
return true;
}
@@ -38,6 +38,9 @@ public class ErpDbContext : DbContext
/// <summary>Singleton row (FR-MD-11).</summary>
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
// --- Cross-cutting (docs/10 Part C.7) ---
public DbSet<User> Users => Set<User>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddNotificationConfigModulePreferences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "notification_config",
columns: table => new
{
NotificationConfigId = table.Column<int>(type: "integer", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_notification_config", x => x.NotificationConfigId);
table.CheckConstraint("ck_notification_config_singleton", "\"NotificationConfigId\" = 1");
table.ForeignKey(
name: "FK_notification_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "notification_config_modules",
columns: table => new
{
NotificationConfigModuleId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
NotificationConfigId = table.Column<int>(type: "integer", nullable: false),
ModuleKey = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
EmailEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
SmsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
InAppEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_notification_config_modules", x => x.NotificationConfigModuleId);
table.ForeignKey(
name: "FK_notification_config_modules_notification_config_Notificatio~",
column: x => x.NotificationConfigId,
principalTable: "notification_config",
principalColumn: "NotificationConfigId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.Sql("""
INSERT INTO sub_nav_items ("SubNavItemId", "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder")
VALUES (24, 9, 'settings.notification-config', '/dashboard/settings/notification-config', NULL, 'Notification Config', 3)
ON CONFLICT ("SubNavItemId") DO UPDATE
SET "NavItemId" = EXCLUDED."NavItemId",
"Code" = EXCLUDED."Code",
"Href" = EXCLUDED."Href",
"Icon" = EXCLUDED."Icon",
"Label" = EXCLUDED."Label",
"SortOrder" = EXCLUDED."SortOrder";
""");
migrationBuilder.CreateIndex(
name: "IX_notification_config_UpdatedBy",
table: "notification_config",
column: "UpdatedBy");
migrationBuilder.CreateIndex(
name: "IX_notification_config_modules_ModuleKey",
table: "notification_config_modules",
column: "ModuleKey",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_notification_config_modules_NotificationConfigId",
table: "notification_config_modules",
column: "NotificationConfigId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "notification_config_modules");
migrationBuilder.DropTable(
name: "notification_config");
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 24);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddProductConfigFeatureFlags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "BatchListsEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "SellingPricesEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "SerialNumbersEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "WarrantyPeriodsEnabled",
table: "product_config",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BatchListsEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "SellingPricesEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "SerialNumbersEnabled",
table: "product_config");
migrationBuilder.DropColumn(
name: "WarrantyPeriodsEnabled",
table: "product_config");
}
}
}
@@ -2354,6 +2354,74 @@ namespace ERPCore.Migrations
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
{
b.Property<int>("NotificationConfigId")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("UpdatedBy")
.HasColumnType("integer");
b.HasKey("NotificationConfigId");
b.HasIndex("UpdatedBy");
b.ToTable("notification_config", null, t =>
{
t.HasCheckConstraint("ck_notification_config_singleton", "\"NotificationConfigId\" = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfigModule", b =>
{
b.Property<int>("NotificationConfigModuleId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("NotificationConfigModuleId"));
b.Property<bool>("EmailEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("InAppEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("ModuleKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("NotificationConfigId")
.HasColumnType("integer");
b.Property<bool>("SmsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.HasKey("NotificationConfigModuleId");
b.HasIndex("ModuleKey")
.IsUnique();
b.HasIndex("NotificationConfigId");
b.ToTable("notification_config_modules", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b =>
{
b.Property<int>("SequenceId")
@@ -2973,6 +3041,11 @@ namespace ERPCore.Migrations
b.Property<int>("ConfigId")
.HasColumnType("integer");
b.Property<bool>("BatchListsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("BrandsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
@@ -2989,6 +3062,16 @@ namespace ERPCore.Migrations
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<bool>("SellingPricesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("SerialNumbersEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("SubcategoriesEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
@@ -3000,6 +3083,11 @@ namespace ERPCore.Migrations
b.Property<int?>("UpdatedBy")
.HasColumnType("integer");
b.Property<bool>("WarrantyPeriodsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.HasKey("ConfigId");
b.HasIndex("UpdatedBy");
@@ -5189,6 +5277,16 @@ namespace ERPCore.Migrations
Status = "Active"
},
new
{
SubNavItemId = 24,
Code = "settings.notification-config",
Href = "/dashboard/settings/notification-config",
Label = "Notification Config",
NavItemId = 9,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 23,
Code = "sales.bundle-sales",
@@ -6284,6 +6382,27 @@ namespace ERPCore.Migrations
b.Navigation("PayrollRun");
});
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfigModule", b =>
{
b.HasOne("ERPCore.Domain.Entities.NotificationConfig", "NotificationConfig")
.WithMany("Modules")
.HasForeignKey("NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{
b.HasOne("ERPCore.Domain.Entities.Employee", "Employee")
@@ -7416,6 +7535,11 @@ namespace ERPCore.Migrations
b.Navigation("Children");
});
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
{
b.Navigation("Modules");
});
modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b =>
{
b.Navigation("Components");
+2
View File
@@ -85,6 +85,8 @@ builder.Services.AddScoped<IBrandService, BrandService>();
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
builder.Services.AddScoped<IVendorService, VendorService>();
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
@@ -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);
}