first commit

This commit is contained in:
2026-07-09 10:02:08 +05:30
commit 438ab2cdbd
27 changed files with 2164 additions and 0 deletions
@@ -0,0 +1,24 @@
namespace ERPCore.Common.Models;
/// <summary>Pagination envelope metadata returned alongside a page of items.</summary>
public sealed record PaginationMeta(int Page, int PageSize, int TotalCount)
{
public int TotalPages => PageSize <= 0 ? 0 : (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasPrevious => Page > 1;
public bool HasNext => Page < TotalPages;
}
/// <summary>A single page of <typeparamref name="T"/> plus its pagination metadata.</summary>
public sealed class PagedResult<T>
{
public IReadOnlyList<T> Items { get; init; } = Array.Empty<T>();
public PaginationMeta Meta { get; init; } = new(1, 0, 0);
public PagedResult() { }
public PagedResult(IReadOnlyList<T> items, int page, int pageSize, int totalCount)
{
Items = items;
Meta = new PaginationMeta(page, pageSize, totalCount);
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace ERPCore.Common.Models;
/// <summary>
/// Lightweight success/failure envelope for service-layer results where an
/// exception would be overkill. Domain errors that must reach the client as
/// RFC 7807 ProblemDetails should throw a DomainException instead
/// (see System/Errors).
/// </summary>
public readonly struct Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string? ErrorCode { get; }
public string? ErrorMessage { get; }
private Result(bool ok, T? value, string? code, string? message)
{
IsSuccess = ok;
Value = value;
ErrorCode = code;
ErrorMessage = message;
}
public static Result<T> Success(T value) => new(true, value, null, null);
public static Result<T> Failure(string code, string message) => new(false, default, code, message);
}
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Non-domain metadata endpoint. Confirms the controller pipeline, Swagger and
/// routing are wired; domain endpoints are defined per docs/11-BACKEND-PHASE1.md.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public sealed class MetaController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok(new
{
name = "ERPCore API",
phase = "Phase 1 — Inventory & Supply Chain",
version = "v1",
utc = DateTime.UtcNow
});
}
+33
View File
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>
<ItemGroup>
<Folder Include="Common\Models\" />
<Folder Include="Infra\UoW\" />
<Folder Include="System\Errors\" />
</ItemGroup>
</Project>
+31
View File
@@ -0,0 +1,31 @@
using System.Security.Claims;
namespace ERPCore.Infra.Auth;
/// <summary>
/// Resolves the audit actor from the current HTTP request's principal
/// (token `sub` / NameIdentifier). Falls back to <see cref="SystemActor"/>
/// when the request is unauthenticated (e.g. health checks, seeding).
/// </summary>
public sealed class CurrentUser : ICurrentUser
{
public const string SystemActor = "system";
private readonly IHttpContextAccessor _accessor;
public CurrentUser(IHttpContextAccessor accessor) => _accessor = accessor;
public bool IsAuthenticated =>
_accessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
public string UserId
{
get
{
var user = _accessor.HttpContext?.User;
var sub = user?.FindFirstValue(ClaimTypes.NameIdentifier)
?? user?.FindFirstValue("sub");
return string.IsNullOrWhiteSpace(sub) ? SystemActor : sub;
}
}
}
@@ -0,0 +1,15 @@
namespace ERPCore.Infra.Auth;
/// <summary>
/// Abstraction over the authenticated principal, used purely as the audit actor
/// (RBAC is deferred for Phase 1). The actor is derived from the token's `sub`
/// claim — never from the request body (see 00-CORE §4).
/// </summary>
public interface ICurrentUser
{
/// <summary>The audit actor identity (token `sub`), or "system" when unauthenticated.</summary>
string UserId { get; }
/// <summary>True when the request carries an authenticated principal.</summary>
bool IsAuthenticated { get; }
}
@@ -0,0 +1,39 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
namespace ERPCore.Infra.Auth;
/// <summary>
/// JWT bearer wiring. Authentication only — RBAC/authorization policies are
/// deferred for Phase 1; the validated principal exists solely so that
/// <see cref="ICurrentUser"/> can stamp the audit actor.
/// </summary>
public static class JwtAuthExtensions
{
public static IServiceCollection AddErpJwtAuth(this IServiceCollection services, IConfiguration config)
{
var issuer = config["Jwt:Issuer"];
var audience = config["Jwt:Audience"];
var signingKey = config["Jwt:SigningKey"] ?? string.Empty;
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudience = audience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)),
ClockSkew = TimeSpan.FromSeconds(30)
};
});
services.AddAuthorization();
return services;
}
}
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Persistence;
/// <summary>
/// EF Core context for the ERP database. The 38 Phase 1 entities and their
/// <see cref="IEntityTypeConfiguration{TEntity}"/> configurations are added under
/// Domain/Entities and Infra/Persistence/Configurations as they are implemented.
/// The authoritative schema lives in docs/10-BACKEND-PHASE1.md — do not invent it here.
/// </summary>
public class ErpDbContext : DbContext
{
public ErpDbContext(DbContextOptions<ErpDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Pick up every IEntityTypeConfiguration in this assembly
// (Infra/Persistence/Configurations/*).
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly);
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace ERPCore.Infra.UoW;
/// <summary>
/// Transaction boundary for stock-affecting operations (00-CORE §4). Every
/// GRN confirm, transfer dispatch/receive, adjustment, return and count post
/// runs inside a single UoW transaction so FIFO layer consumption and ledger
/// writes commit or roll back together.
/// </summary>
public interface IUnitOfWork
{
/// <summary>Persist tracked changes without an explicit transaction scope.</summary>
Task<int> SaveChangesAsync(CancellationToken ct = default);
/// <summary>Run <paramref name="action"/> inside a transaction, saving and committing on success.</summary>
Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default);
/// <summary>Transactional variant that returns a value from <paramref name="action"/>.</summary>
Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default);
}
+47
View File
@@ -0,0 +1,47 @@
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.UoW;
/// <summary>
/// EF Core-backed unit of work. Uses the provider execution strategy so the
/// transactional path is safe under connection resiliency / retries.
/// </summary>
public sealed class UnitOfWork : IUnitOfWork
{
private readonly ErpDbContext _db;
public UnitOfWork(ErpDbContext db) => _db = db;
public Task<int> SaveChangesAsync(CancellationToken ct = default) => _db.SaveChangesAsync(ct);
public async Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
{
await ExecuteInTransactionAsync<object?>(async token =>
{
await action(token);
return null;
}, ct);
}
public async Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
{
var strategy = _db.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
await using var tx = await _db.Database.BeginTransactionAsync(ct);
try
{
var result = await action(ct);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return result;
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
});
}
}
+61
View File
@@ -0,0 +1,61 @@
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Infra.UoW;
using ERPCore.Repositories;
using ERPCore.Repositories.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Serilog (file sink) — reads levels from configuration, plus a rolling daily file.
builder.Host.UseSerilog((ctx, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day));
builder.Services.AddControllers();
// EF Core + PostgreSQL
builder.Services.AddDbContext<ErpDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
// ProblemDetails (RFC 7807) + domain-exception mapping
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
// JWT bearer auth (RBAC deferred; identity used only for the audit stamp)
builder.Services.AddErpJwtAuth(builder.Configuration);
// Current-user (audit actor) derived from token `sub`
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
// Unit of work + generic repository base
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// Health checks (EF Core DB)
builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>();
// Swagger / OpenAPI (Swashbuckle v10 → OpenAPI 3.1)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(o => o.SwaggerDoc("v1", new OpenApiInfo { Title = "ERPCore API", Version = "v1" }));
var app = builder.Build();
app.UseSerilogRequestLogging();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseExceptionHandler(); // -> DomainExceptionHandler / ProblemDetails
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5224",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7112;http://localhost:5224",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,19 @@
namespace ERPCore.Repositories.Interfaces;
/// <summary>
/// Generic data-access base. Repositories own EF entities; business rules stay
/// in services (00-CORE §4). Concrete repositories extend this with query
/// methods specific to their aggregate.
/// </summary>
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(object id, CancellationToken ct = default);
Task<IReadOnlyList<T>> ListAsync(CancellationToken ct = default);
/// <summary>Composable query root for service-defined filtering/paging.</summary>
IQueryable<T> Query();
Task AddAsync(T entity, CancellationToken ct = default);
void Update(T entity);
void Remove(T entity);
}
@@ -0,0 +1,37 @@
using ERPCore.Infra.Persistence;
using ERPCore.Repositories.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Repositories;
/// <summary>
/// Default <see cref="IRepository{T}"/> implementation over <see cref="ErpDbContext"/>.
/// Registered open-generically so any entity gets a repository for free; specialise
/// by deriving when an aggregate needs bespoke queries.
/// </summary>
public class Repository<T> : IRepository<T> where T : class
{
protected readonly ErpDbContext Db;
protected readonly DbSet<T> Set;
public Repository(ErpDbContext db)
{
Db = db;
Set = db.Set<T>();
}
public virtual async Task<T?> GetByIdAsync(object id, CancellationToken ct = default)
=> await Set.FindAsync(new[] { id }, ct);
public virtual async Task<IReadOnlyList<T>> ListAsync(CancellationToken ct = default)
=> await Set.AsNoTracking().ToListAsync(ct);
public IQueryable<T> Query() => Set.AsQueryable();
public virtual async Task AddAsync(T entity, CancellationToken ct = default)
=> await Set.AddAsync(entity, ct);
public virtual void Update(T entity) => Set.Update(entity);
public virtual void Remove(T entity) => Set.Remove(entity);
}
@@ -0,0 +1,32 @@
namespace ERPCore.System.Errors;
/// <summary>
/// Base for domain / business-rule violations. Carries a stable <see cref="Code"/>
/// (see the error catalog in docs/11-BACKEND-PHASE1.md) and an HTTP
/// <see cref="StatusCode"/>; mapped to RFC 7807 ProblemDetails by
/// <see cref="DomainExceptionHandler"/>.
/// </summary>
public class DomainException : Exception
{
public string Code { get; }
public int StatusCode { get; }
public DomainException(string code, string message, int statusCode = 400)
: base(message)
{
Code = code;
StatusCode = statusCode;
}
}
/// <summary>404 — a referenced resource does not exist.</summary>
public sealed class NotFoundException : DomainException
{
public NotFoundException(string message) : base(ErrorCodes.NotFound, message, 404) { }
}
/// <summary>409 — the request conflicts with current server state.</summary>
public sealed class ConflictException : DomainException
{
public ConflictException(string message) : base(ErrorCodes.Conflict, message, 409) { }
}
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.System.Errors;
/// <summary>
/// Translates <see cref="DomainException"/> into an RFC 7807 ProblemDetails
/// response, preserving the stable domain <c>code</c> as an extension. Non-domain
/// exceptions are left unhandled so the framework returns a 500 ProblemDetails.
/// Registered via AddExceptionHandler and invoked by UseExceptionHandler.
/// </summary>
public sealed class DomainExceptionHandler : IExceptionHandler
{
private readonly IProblemDetailsService _problemDetails;
public DomainExceptionHandler(IProblemDetailsService problemDetails)
=> _problemDetails = problemDetails;
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
if (exception is not DomainException domain)
return false;
httpContext.Response.StatusCode = domain.StatusCode;
return await _problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = domain,
ProblemDetails = new ProblemDetails
{
Status = domain.StatusCode,
Title = "Domain rule violation",
Detail = domain.Message,
Extensions = { ["code"] = domain.Code }
}
});
}
}
@@ -0,0 +1,14 @@
namespace ERPCore.System.Errors;
/// <summary>
/// Stable domain error codes carried in the <c>code</c> extension of RFC 7807
/// ProblemDetails responses. The authoritative catalog lives in
/// docs/11-BACKEND-PHASE1.md; add codes here as endpoints are implemented so
/// the two stay in sync.
/// </summary>
public static class ErrorCodes
{
public const string Validation = "validation_error";
public const string NotFound = "not_found";
public const string Conflict = "conflict";
}
+18
View File
@@ -0,0 +1,18 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=CHANGE_ME;Password=CHANGE_ME"
},
"Jwt": {
"Issuer": "ERPCore",
"Audience": "ERPCore.Clients",
"SigningKey": "CHANGE_ME_DEV_ONLY_32+_CHARS",
"AccessTokenMinutes": 120
},
"AllowedHosts": "*"
}