new updates

This commit is contained in:
2026-09-17 14:22:45 +05:30
committed by ImanThiyanga
parent e1f19fe413
commit 8c3d62223c
261 changed files with 102091 additions and 549 deletions
@@ -28,8 +28,13 @@ public sealed class AuthHexClient : IAuthHexClient
};
private readonly HttpClient _http;
private readonly IConfiguration _config;
public AuthHexClient(HttpClient http) => _http = http;
public AuthHexClient(HttpClient http, IConfiguration config)
{
_http = http;
_config = config;
}
// ---- UserManager --------------------------------------------------
@@ -87,6 +92,19 @@ public sealed class AuthHexClient : IAuthHexClient
public Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct)
=> CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct);
public Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct)
{
var adminApiKey = _config["AuthHex:AdminApiKey"];
if (string.IsNullOrEmpty(adminApiKey))
throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call the admin deleteUser function.", 500);
return CallAsync<DeleteAuthHexUserResult>(
"user", "deleteUser",
new { email, mobileNumber, nic, userName = username },
null, ct,
extraHeaders: new Dictionary<string, string> { ["X-Admin-Api-Key"] = adminApiKey });
}
// ---- RecoveryManager ------------------------------------------------
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
@@ -134,11 +152,14 @@ public sealed class AuthHexClient : IAuthHexClient
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
=> await CallAsync<JsonElement?>(routeGroup, functionName, payload, bearerToken, ct);
private async Task<T> CallAsync<T>(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
private async Task<T> CallAsync<T>(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct, Dictionary<string, string>? extraHeaders = null)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/{routeGroup}");
if (!string.IsNullOrEmpty(bearerToken))
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
if (extraHeaders is not null)
foreach (var (key, value) in extraHeaders)
httpRequest.Headers.Add(key, value);
httpRequest.Content = JsonContent.Create(
new AuthHexRequestBody { FunctionName = functionName, Payload = payload, Reference = string.Empty },
@@ -30,6 +30,14 @@ public interface IAuthHexClient
Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct);
Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct);
/// <summary>
/// Admin-only: permanently deletes every AuthHex identity matching any of the given
/// identifiers, so a stale email/NIC/username/mobile no longer blocks re-registration
/// (CanRegisterUser's duplicate check has no notion of "deleted" — this is the only way
/// to actually clear one). Gated by AuthHex:AdminApiKey — throws if unset or rejected.
/// </summary>
Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct);
// RecoveryManager (POST /api/recovery)
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
@@ -14,4 +14,12 @@ public static class AuthHexClaims
public const string RoleId = "RoleId";
public const string RoleCode = "RoleCode";
public const string Nic = "NIC";
/// <summary>
/// Local (non-AuthHex) claim injected by <see cref="ShadowUserClaimsTransformation"/> —
/// the resolved local user's <see cref="Entities.Employee.BranchId"/>, used by
/// <see cref="ICurrentUser.BranchId"/>/<see cref="ICurrentBranchContext"/> for
/// branch-scoped query filtering (docs/BRANCH_ISOLATION_PLAN.md).
/// </summary>
public const string BranchId = "erp:BranchId";
}
@@ -35,4 +35,13 @@ public sealed class CurrentUser : ICurrentUser
public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.RoleCode);
public bool IsAdmin => string.Equals(RoleCode?.Trim(), "Admin", StringComparison.OrdinalIgnoreCase);
public int? BranchId
{
get
{
var raw = _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.BranchId);
return int.TryParse(raw, out var id) ? id : null;
}
}
}
@@ -0,0 +1,34 @@
namespace ERPCore.Infra.Auth;
/// <summary>
/// Scoped accessor feeding <see cref="Persistence.ErpDbContext"/>'s global query filters
/// (docs/BRANCH_ISOLATION_PLAN.md — "What this doesn't solve by itself" / enforcement
/// layer). EF Core query filters can't read ambient HTTP state directly, so this is
/// resolved once per request/scope and injected into the DbContext constructor instead.
/// </summary>
public interface ICurrentBranchContext
{
/// <summary>The branch to scope reads to. Null means "don't filter" — used for
/// admins, unauthenticated/system contexts, and any caller with no resolved branch.</summary>
int? BranchId { get; }
/// <summary>False disables filtering entirely (e.g. background jobs, migrations,
/// admin callers) regardless of <see cref="BranchId"/>.</summary>
bool IsActive { get; }
}
/// <summary>
/// Default implementation: derives from <see cref="ICurrentUser"/>. Admins and users with
/// no resolved branch are never filtered — the same bypass <see cref="Services.Interfaces.IBranchAccessService"/>
/// already applies for write-path checks.
/// </summary>
public sealed class CurrentBranchContext : ICurrentBranchContext
{
private readonly ICurrentUser _currentUser;
public CurrentBranchContext(ICurrentUser currentUser) => _currentUser = currentUser;
public int? BranchId => _currentUser.IsAdmin ? null : _currentUser.BranchId;
public bool IsActive => _currentUser.IsAuthenticated && !_currentUser.IsAdmin && _currentUser.BranchId is not null;
}
@@ -29,4 +29,12 @@ public interface ICurrentUser
/// the only role that bypasses branch-scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>).
/// </summary>
bool IsAdmin { get; }
/// <summary>
/// The resolved local user's branch (<see cref="Entities.Employee.BranchId"/>), read from
/// the local claim injected by <see cref="ShadowUserClaimsTransformation"/>. Null when the
/// user has no linked employee, is unauthenticated, or is an admin (admins are never scoped —
/// see <see cref="Services.Interfaces.IBranchAccessService"/> and <see cref="ICurrentBranchContext"/>).
/// </summary>
int? BranchId { get; }
}
@@ -0,0 +1,125 @@
using ERPCore.Domain.Enums;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
namespace ERPCore.Infra.Auth;
/// <summary>
/// Per-endpoint RBAC (docs/02-SECURITY.md AR-01): "can this role reach this module at
/// all", reusing the exact <c>RolePermission</c> data the sidebar already filters on
/// (<see cref="INavPermissionService"/>) instead of leaving every controller open to
/// any authenticated user. Apply via <c>[Authorize(Policy = NavPolicies.For("..."))]</c>
/// alongside the base <see cref="JwtAuthExtensions.ErpAccessPolicy"/> already on
/// <see cref="Controllers.ApiControllerBase"/> — multiple <c>[Authorize]</c> attributes
/// combine as AND, so both must pass.
///
/// A second, optional layer narrows within a module: appending
/// <c>":{RolePermissionAction}"</c> (e.g. <c>NavPolicies.Prefix + "procurement:" +
/// NavPolicies.DeleteAction</c>) checks the role's granted <c>RolePermission.Actions</c>
/// bitmask instead of only "can reach this module" — put this on the specific actions
/// (delete, approve, …) a module-level viewer role should not get. Without the suffix the
/// policy stays module-only, matching existing controller-level attributes.
/// </summary>
public static class NavPolicies
{
/// <summary>
/// Prepend to a nav-item/sub-nav-item <c>Code</c> (e.g. "procurement", not
/// "NAV:procurement" — the "NAV:" prefix lives on <c>Permission.Code</c>, not here)
/// to get the policy name: <c>NavPolicies.Prefix + "procurement"</c>. An attribute
/// argument must be a compile-time constant, so this can't be a method — write the
/// concatenation out at each call site instead of introducing a <c>For(...)</c> helper.
/// </summary>
public const string Prefix = "Nav:";
/// <summary>Separator between the nav code and the action suffix, e.g. <c>"Nav:procurement:Delete"</c>.</summary>
public const string ActionSeparator = ":";
// One const per RolePermissionAction flag, spelled to match Enum.Parse — kept as
// consts (not the enum itself) so call sites stay compile-time-constant attribute args.
public const string CreateAction = "Create";
public const string EditAction = "Edit";
public const string DeleteAction = "Delete";
public const string ApproveAction = "Approve";
}
/// <summary>The nav module code a request must be permitted for, and optionally the specific
/// action within it — see <see cref="NavPolicies"/>.</summary>
public sealed class NavRequirement : IAuthorizationRequirement
{
public string NavCode { get; }
public RolePermissionAction? Action { get; }
public NavRequirement(string navCode, RolePermissionAction? action = null)
{
NavCode = navCode;
Action = action;
}
}
/// <summary>Admin always passes (mirrors every other Admin-bypasses-scoping rule in this
/// codebase, e.g. <c>BranchAccessService</c>); everyone else needs the role permission,
/// and — when the policy names one — the specific action within it too.</summary>
public sealed class NavAuthorizationHandler : AuthorizationHandler<NavRequirement>
{
private readonly ICurrentUser _currentUser;
private readonly INavPermissionService _navPermissions;
public NavAuthorizationHandler(ICurrentUser currentUser, INavPermissionService navPermissions)
{
_currentUser = currentUser;
_navPermissions = navPermissions;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, NavRequirement requirement)
{
if (!_currentUser.IsAuthenticated) return;
if (_currentUser.IsAdmin)
{
context.Succeed(requirement);
return;
}
if (!await _navPermissions.HasAccessAsync(_currentUser.RoleCode, requirement.NavCode)) return;
if (requirement.Action is null || await _navPermissions.HasActionAsync(_currentUser.RoleCode, requirement.NavCode, requirement.Action.Value))
context.Succeed(requirement);
}
}
/// <summary>
/// Resolves <c>"Nav:{code}"</c> and <c>"Nav:{code}:{Action}"</c> policy names to a
/// <see cref="NavRequirement"/> on demand (so a policy doesn't need pre-registering per
/// nav code in <c>AddAuthorization</c>); everything else (notably
/// <see cref="JwtAuthExtensions.ErpAccessPolicy"/>) falls through to the framework
/// default provider unchanged.
/// </summary>
public sealed class NavPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallback;
public NavPolicyProvider(IOptions<AuthorizationOptions> options) => _fallback = new DefaultAuthorizationPolicyProvider(options);
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.StartsWith(NavPolicies.Prefix, StringComparison.Ordinal))
{
var rest = policyName[NavPolicies.Prefix.Length..];
var sepIndex = rest.LastIndexOf(NavPolicies.ActionSeparator, StringComparison.Ordinal);
NavRequirement requirement;
if (sepIndex >= 0 && Enum.TryParse<RolePermissionAction>(rest[(sepIndex + 1)..], out var action))
requirement = new NavRequirement(rest[..sepIndex], action);
else
requirement = new NavRequirement(rest);
var policy = new AuthorizationPolicyBuilder().AddRequirements(requirement).Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
return _fallback.GetPolicyAsync(policyName);
}
}
@@ -37,6 +37,14 @@ public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
var localId = await ResolveOrProvisionAsync(authUserId, nic);
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, localId.ToString()));
var branchId = await _db.Employees.AsNoTracking()
.Where(e => e.UserId == localId)
.Select(e => (int?)e.BranchId)
.FirstOrDefaultAsync();
if (branchId is not null)
identity.AddClaim(new Claim(AuthHexClaims.BranchId, branchId.Value.ToString()));
return principal;
}
@@ -13,6 +13,9 @@ public sealed class BatchConfiguration : IEntityTypeConfiguration<Batch>
builder.Property(b => b.BatchNo).IsRequired().HasMaxLength(50);
builder.Property(b => b.Barcode).HasMaxLength(100);
builder.HasIndex(b => b.Barcode).IsUnique();
builder.HasOne(b => b.Item)
.WithMany()
.HasForeignKey(b => b.ItemId)
@@ -33,6 +36,9 @@ public sealed class SerialConfiguration : IEntityTypeConfiguration<Serial>
builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100);
builder.Property(s => s.Status).IsRequired().HasMaxLength(20);
builder.Property(s => s.Barcode).HasMaxLength(100);
builder.HasIndex(s => s.Barcode).IsUnique();
builder.HasOne(s => s.Item)
.WithMany()
.HasForeignKey(s => s.ItemId)
@@ -46,5 +46,9 @@ public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
builder.HasIndex(c => c.Status);
builder.HasIndex(c => c.CustomerType);
builder.HasOne(c => c.Branch).WithMany()
.HasForeignKey(c => c.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(c => c.BranchId);
}
}
@@ -15,9 +15,13 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
builder.Property(i => i.Sku).IsRequired().HasMaxLength(50);
builder.HasIndex(i => i.Sku).IsUnique();
// Postgres unique indexes treat multiple NULLs as distinct, so plain IsUnique() is
// correct here — no filter needed for the optional scan code.
builder.Property(i => i.Barcode).HasMaxLength(100);
builder.HasIndex(i => i.Barcode).IsUnique();
builder.Property(i => i.Name).IsRequired().HasMaxLength(200);
builder.Property(i => i.Description).HasMaxLength(1000);
builder.Property(i => i.TaxClass).HasMaxLength(20);
// Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value).
builder.Property(i => i.SellingMinPrice).HasPrecision(18, 4);
@@ -75,6 +79,11 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
.HasForeignKey(i => i.DefaultVendorId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.TaxRate)
.WithMany()
.HasForeignKey(i => i.TaxRateId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(i => i.Status);
builder.HasIndex(i => i.CategoryId);
builder.HasIndex(i => i.BrandId);
@@ -0,0 +1,26 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class NotificationConfiguration : IEntityTypeConfiguration<Notification>
{
public void Configure(EntityTypeBuilder<Notification> builder)
{
builder.ToTable("notifications");
builder.HasKey(n => n.NotificationId);
builder.Property(n => n.ModuleKey).IsRequired().HasMaxLength(100);
builder.Property(n => n.Title).IsRequired().HasMaxLength(200);
builder.Property(n => n.Message).IsRequired().HasMaxLength(1000);
builder.Property(n => n.RelatedEntityType).HasMaxLength(100);
builder.HasIndex(n => n.CreatedAt);
builder.HasMany(n => n.Recipients)
.WithOne(r => r.Notification)
.HasForeignKey(r => r.NotificationId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,21 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class NotificationRecipientConfiguration : IEntityTypeConfiguration<NotificationRecipient>
{
public void Configure(EntityTypeBuilder<NotificationRecipient> builder)
{
builder.ToTable("notification_recipients");
builder.HasKey(r => r.NotificationRecipientId);
builder.HasIndex(r => new { r.UserId, r.IsRead, r.NotificationId });
builder.HasOne(r => r.User)
.WithMany()
.HasForeignKey(r => r.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -12,10 +12,14 @@ public sealed class NumberSequenceConfiguration : IEntityTypeConfiguration<Numbe
builder.HasKey(s => s.SequenceId);
builder.Property(s => s.DocType).HasColumnName("doc_type").IsRequired().HasMaxLength(10);
builder.Property(s => s.BranchId).HasColumnName("branch_id").IsRequired();
builder.Property(s => s.Year).HasColumnName("year").IsRequired();
builder.Property(s => s.LastNumber).HasColumnName("last_number").IsRequired();
// One counter per (doc type, year); also the ON CONFLICT target for atomic issue.
builder.HasIndex(s => new { s.DocType, s.Year }).IsUnique();
builder.HasOne(s => s.Branch).WithMany()
.HasForeignKey(s => s.BranchId).OnDelete(DeleteBehavior.Restrict);
// One counter per (doc type, branch, year); also the ON CONFLICT target for atomic issue.
builder.HasIndex(s => new { s.DocType, s.BranchId, s.Year }).IsUnique();
}
}
@@ -83,7 +83,8 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 49, Code = "NAV:sales.bundle-templates", SubNavItemId = 23 },
new Permission { PermissionId = 50, Code = "NAV:sales.invoices", SubNavItemId = 40 },
new Permission { PermissionId = 51, Code = "NAV:sales.free-issues", SubNavItemId = 41 },
new Permission { PermissionId = 52, Code = "NAV:sales.day-end", SubNavItemId = 42 }
new Permission { PermissionId = 52, Code = "NAV:sales.day-end", SubNavItemId = 42 },
new Permission { PermissionId = 53, Code = "NAV:products.tax-rates", SubNavItemId = 43 }
);
}
}
@@ -45,6 +45,10 @@ public sealed class PurchaseInvoiceConfiguration : IEntityTypeConfiguration<Purc
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.VendorId);
builder.HasOne(x => x.Branch).WithMany()
.HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => x.BranchId);
builder.HasMany(x => x.Lines)
.WithOne(x => x.PurchaseInvoice)
.HasForeignKey(x => x.PurchaseInvoiceId)
@@ -38,6 +38,10 @@ public sealed class PurchaseOrderConfiguration : IEntityTypeConfiguration<Purcha
builder.HasIndex(p => p.Status);
builder.HasIndex(p => p.VendorId);
builder.HasOne(p => p.Branch).WithMany()
.HasForeignKey(p => p.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(p => p.BranchId);
}
}
@@ -24,6 +24,10 @@ public sealed class RequisitionConfiguration : IEntityTypeConfiguration<Requisit
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(r => r.Status);
builder.HasOne(r => r.Branch).WithMany()
.HasForeignKey(r => r.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(r => r.BranchId);
}
}
@@ -22,6 +22,10 @@ public sealed class RfqConfiguration : IEntityTypeConfiguration<Rfq>
.WithMany()
.HasForeignKey(r => r.RequisitionId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Branch).WithMany()
.HasForeignKey(r => r.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(r => r.BranchId);
}
}
@@ -1,4 +1,5 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -11,6 +12,11 @@ public sealed class RolePermissionConfiguration : IEntityTypeConfiguration<RoleP
builder.ToTable("role_permissions");
builder.HasKey(rp => new { rp.RoleId, rp.PermissionId });
builder.Property(rp => rp.Actions)
.HasConversion<int>()
.IsRequired()
.HasDefaultValue(RolePermissionAction.All);
builder.HasOne(rp => rp.Role).WithMany()
.HasForeignKey(rp => rp.RoleId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(rp => rp.Permission).WithMany()
@@ -16,7 +16,10 @@ public sealed class SalesDayEndConfiguration : IEntityTypeConfiguration<SalesDay
builder.Property(x => x.BusinessDate).IsRequired();
foreach (var p in new[] { nameof(SalesDayEnd.Subtotal), nameof(SalesDayEnd.DiscountTotal), nameof(SalesDayEnd.TaxTotal), nameof(SalesDayEnd.GrandTotal), nameof(SalesDayEnd.CostOfGoodsSold) })
foreach (var p in new[] {
nameof(SalesDayEnd.Subtotal), nameof(SalesDayEnd.DiscountTotal), nameof(SalesDayEnd.TaxTotal), nameof(SalesDayEnd.GrandTotal),
nameof(SalesDayEnd.PaidTotal), nameof(SalesDayEnd.BalanceTotal), nameof(SalesDayEnd.CostOfGoodsSold)
})
builder.Property<decimal>(p).HasPrecision(18, 4);
builder.Property(x => x.BundleCount).HasDefaultValue(0);
@@ -84,6 +84,16 @@ public sealed class SalesSlipLineConfiguration : IEntityTypeConfiguration<SalesS
.HasForeignKey(x => x.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Batch)
.WithMany()
.HasForeignKey(x => x.BatchId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Serial)
.WithMany()
.HasForeignKey(x => x.SerialId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.PriceSource).HasMaxLength(50);
builder.Property(x => x.RowVersion).IsRowVersion();
}
@@ -42,5 +42,7 @@ public sealed class StockCountLineConfiguration : IEntityTypeConfiguration<Stock
builder.HasOne(l => l.Count).WithMany(c => c.Lines).HasForeignKey(l => l.CountId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Serial).WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -31,6 +31,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 4, NavItemId = 2, Code = "products.item-type", Label = "Item Type", Href = "/dashboard/products/item-types", SortOrder = 4 },
new SubNavItem { SubNavItemId = 5, NavItemId = 2, Code = "products.uom", Label = "UOM", Href = "/dashboard/products/uoms", SortOrder = 5 },
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
new SubNavItem { SubNavItemId = 43, NavItemId = 2, Code = "products.tax-rates", Label = "Tax Rates", Href = "/dashboard/products/tax-rates", SortOrder = 7 },
new SubNavItem { SubNavItemId = 29, NavItemId = 9, Code = "settings.branches", Label = "Branches", Href = "/dashboard/settings/branches", SortOrder = 0 },
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 },
@@ -0,0 +1,26 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class SupportTicketConfiguration : IEntityTypeConfiguration<SupportTicket>
{
public void Configure(EntityTypeBuilder<SupportTicket> builder)
{
builder.ToTable("support_tickets");
builder.HasKey(t => t.TicketId);
builder.Property(t => t.Subject).IsRequired().HasMaxLength(200);
builder.Property(t => t.Status).IsRequired().HasConversion<string>().HasMaxLength(20);
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.CreatedBy);
builder.HasIndex(t => t.Status);
builder.HasMany(t => t.Messages)
.WithOne(m => m.Ticket)
.HasForeignKey(m => m.TicketId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,18 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class SupportTicketMessageConfiguration : IEntityTypeConfiguration<SupportTicketMessage>
{
public void Configure(EntityTypeBuilder<SupportTicketMessage> builder)
{
builder.ToTable("support_ticket_messages");
builder.HasKey(m => m.MessageId);
builder.Property(m => m.Message).IsRequired().HasMaxLength(2000);
builder.HasIndex(m => new { m.TicketId, m.CreatedAt });
}
}
@@ -0,0 +1,42 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class TaxRateConfiguration : IEntityTypeConfiguration<TaxRate>
{
public void Configure(EntityTypeBuilder<TaxRate> builder)
{
builder.ToTable("tax_rates");
builder.HasKey(t => t.TaxRateId);
builder.Property(t => t.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(t => t.Code).IsUnique();
builder.Property(t => t.Name).IsRequired().HasMaxLength(100);
builder.Property(t => t.Percentage).HasPrecision(5, 2);
builder.Property(t => t.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(t => t.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.Status);
// Seeded so Item.TaxRateId has something real to point at immediately — codes
// match common VAT/GST categories; admins can rename, add, or retire rates
// afterward (percentages are typically jurisdiction-specific).
var seededAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
builder.HasData(
new TaxRate { TaxRateId = 1, Code = "STANDARD", Name = "Standard", Percentage = 15m, CreatedAt = seededAt },
new TaxRate { TaxRateId = 2, Code = "ZERO", Name = "Zero-Rated", Percentage = 0m, CreatedAt = seededAt },
new TaxRate { TaxRateId = 3, Code = "EXEMPT", Name = "Exempt", Percentage = 0m, CreatedAt = seededAt }
);
}
}
@@ -30,5 +30,9 @@ public sealed class VendorConfiguration : IEntityTypeConfiguration<Vendor>
builder.Property(v => v.RowVersion).IsRowVersion();
builder.HasIndex(v => v.Status);
builder.HasOne(v => v.Branch).WithMany()
.HasForeignKey(v => v.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(v => v.BranchId);
}
}
@@ -15,11 +15,13 @@ namespace ERPCore.Infra.Persistence;
public class ErpDbContext : DbContext
{
private readonly ICurrentUser _currentUser;
private readonly ICurrentBranchContext _currentBranch;
private bool _writingAuditLogs;
public ErpDbContext(DbContextOptions<ErpDbContext> options, ICurrentUser currentUser) : base(options)
public ErpDbContext(DbContextOptions<ErpDbContext> options, ICurrentUser currentUser, ICurrentBranchContext currentBranch) : base(options)
{
_currentUser = currentUser;
_currentBranch = currentBranch;
}
// --- Master Data (docs/10 Part C.1) ---
@@ -30,6 +32,7 @@ public class ErpDbContext : DbContext
/// <summary>Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9).</summary>
public DbSet<ItemType> ItemTypes => Set<ItemType>();
public DbSet<Uom> Uoms => Set<Uom>();
public DbSet<TaxRate> TaxRates => Set<TaxRate>();
public DbSet<Item> Items => Set<Item>();
public DbSet<ItemReorder> ItemReorders => Set<ItemReorder>();
public DbSet<Vendor> Vendors => Set<Vendor>();
@@ -43,6 +46,12 @@ public class ErpDbContext : DbContext
/// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<NotificationRecipient> NotificationRecipients => Set<NotificationRecipient>();
/// <summary>Help-desk tickets raised from the dashboard Help page.</summary>
public DbSet<SupportTicket> SupportTickets => Set<SupportTicket>();
public DbSet<SupportTicketMessage> SupportTicketMessages => Set<SupportTicketMessage>();
// --- Cross-cutting (docs/10 Part C.7) ---
public DbSet<User> Users => Set<User>();
@@ -176,6 +185,19 @@ public class ErpDbContext : DbContext
// (Infra/Persistence/Configurations/*).
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly);
// Global read-side branch scoping (docs/BRANCH_ISOLATION_PLAN.md). Only for entities
// that carry an explicit BranchId directly — warehouse-inherited entities (Grn,
// StockLayer, StockTransfer, ...) are NOT covered here (would need an extra join in
// the filter) and remain a follow-up. !IsActive short-circuits for admins/system
// contexts so the filter becomes a no-op rather than excluding everything.
modelBuilder.Entity<Warehouse>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<Vendor>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<Customer>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<Requisition>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<Rfq>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
// Npgsql requires DateTime values written to `timestamp with time zone` columns
// to have Kind=Utc; dates deserialized from a JSON request body (hire date,
// salary-structure effective date, etc.) come in as Kind=Unspecified and would