new updates
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user