refinement phase 9
This commit is contained in:
+28
-5
@@ -35,7 +35,7 @@ You are a **senior .NET software engineer** working on this codebase. That means
|
||||
- **EF Core 10** + **SQL Server** (Repository + Unit of Work pattern)
|
||||
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization
|
||||
- **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging
|
||||
- **OpenTelemetry** + **prometheus-net** for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning
|
||||
- **OpenTelemetry** (metrics + tracing; Prometheus-scrape at `/metrics`, opt-in OTLP export) for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning
|
||||
- **xUnit** + **NSubstitute** for tests
|
||||
- All NuGet versions are centrally pinned in `Directory.Packages.props`
|
||||
|
||||
@@ -98,7 +98,7 @@ src/
|
||||
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed + ReviewsConfig/ — b14 reviews/tags-master (seeded)/tag-links/patient-care-records configs), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository + b14 ReviewRepository + PatientCareRecordRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + Scheduling/ = RecurringJobSchedulerHostedService + Jobs/ (the IRecurringJob crons — refinement-phase-7) + Search/ = SearchIndexMaintainer + SqlNurseSearch)
|
||||
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
|
||||
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider + MockReviewModerationService) + AddCrossCuttingSeams
|
||||
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
|
||||
│ └── Baya.Infrastructure.Monitoring HealthChecks (live/ready split + IObjectStorage write-probe → refs Baya.Application), OpenTelemetry (one stack: metrics + tracing, opt-in OTLP)
|
||||
├── API/
|
||||
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Development-only Dev (dev/last_otp OTP helper, 404 outside Development) + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts + customer BookingReviews (submit) + owner/admin Reviews (tags + moderate status) + admin AdminReviews (moderation queue) + public Nurses (reviews + review_tags) + nurse/owner/admin PatientCareRecords), appsettings*.json
|
||||
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
|
||||
@@ -547,7 +547,8 @@ and giving the previously admin-manual sweeps a schedule, **using no new infrast
|
||||
only external dependency). Jobs, each reading its seeded `platform_configs` cadence key via `IPlatformConfig`:
|
||||
`booking_request_expiry` (1 min const) · `notification_retention` (24 h const) · `verification_expiry_scan`
|
||||
(`verification_expiry_scan_cadence_hours`) · `no_show_sweep` (`no_show_scan_cadence_hours`) ·
|
||||
`weekly_payout_generation` (`nurse_payout_interval_days`). Load-bearing rules:
|
||||
`weekly_payout_generation` (`nurse_payout_interval_days`) · `MoadianReconciliationJob` (6 h, refinement-phase-8) ·
|
||||
`audit_log_retention` (`audit_retention_scan_cadence_hours`, refinement-phase-9). Load-bearing rules:
|
||||
- **Add a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in `AddPersistenceServices`.
|
||||
Phase 8 registers the Moadian reconciliation + refund-settlement poll exactly this way. The scheduler owns the
|
||||
per-tick DI scope, error isolation (a throwing tick never kills the loop), and the lock; a job says only *how
|
||||
@@ -562,6 +563,28 @@ only external dependency). Jobs, each reading its seeded `platform_configs` cade
|
||||
`AdminPayoutsController` neutralizes any request-supplied value.
|
||||
- **Admin manual triggers remain overrides** (the same idempotent commands). The scheduler is **dormant under the
|
||||
`Testing` environment** so integration tests stay deterministic; each job/command is unit-tested directly.
|
||||
- **Audit-log retention (refinement-phase-9 §9.4)** is an `IRecurringJob` (`AuditLogRetentionJob`) over the
|
||||
append-only `ops.AuditLogs`: a **two-tier** sweep via `IAuditLogger.PurgeExpiredAsync` — financial/verification
|
||||
entity types (`Refund`/`NurseClawback`/`NursePayout`/`NursePayoutBatch`/`NurseVerification`/`PlatformConfig`/
|
||||
`PartnerCenter`) keep `audit_retention_financial_days` (default 2555 ≈ 7 yr); everyday rows
|
||||
`audit_retention_general_days` (default 730 ≈ 2 yr). Oldest-first, capped, id-keyed delete; idempotent.
|
||||
|
||||
**Observability (refinement-phase-9).** One **OpenTelemetry** stack (`Baya.Infrastructure.Monitoring`,
|
||||
`SetupOpenTelemetry`): metrics (runtime + ASP.NET Core + the `mediator_meter` histogram) scraped at `/metrics` via
|
||||
the OTel Prometheus exporter, and **tracing** (ASP.NET Core + EF Core) sharing `service.name = Baya.Web.Api`. The
|
||||
duplicate prometheus-net stack was removed. **OTLP export (traces + metrics) is opt-in** — wired only when
|
||||
`OpenTelemetry:Otlp:Endpoint` is set, so an MVP with Prometheus alone runs unchanged. `ApiResult.RequestId` is the
|
||||
W3C trace id (`Activity.Current.TraceId`, `Activity.DefaultIdFormat = W3C`), so a support ticket maps 1:1 to a
|
||||
trace. **Health checks split** (`ConfigureHealthChecks`/`UseHealthChecks`): `/healthz/live` (process, dependency-
|
||||
free), `/healthz/ready` (app DB + `logDb` [deployed only] + an `IObjectStorage` write-probe), `/HealthCheck`
|
||||
(aggregate, kept for compat). **Logs:** deployed envs write **Information+** to `Baya_Logs` (framework categories
|
||||
held at Warning); **no PII/secrets** — the mock SMS sender never logs the OTP code; clinical text/IBANs are
|
||||
encrypted/masked. The dead Elasticsearch sink + package were removed (SQL sink is the deployed default; set the
|
||||
OTLP collector to ship logs off-box). **gRPC reflection is Development-only** (`GrpcPluginStartup` gates
|
||||
`AddGrpcReflection`/`MapGrpcReflectionService` on `IsDevelopment`); the plugin shares the mixed-protocol Kestrel
|
||||
listener. **`TicketMessage.Body` is encrypted at rest** through `IFieldEncryptor` (converter in
|
||||
`ApplicationDbContext`; column widened to `nvarchar(max)`; the 4000-char cap stays a boundary-validation rule) —
|
||||
ticket bodies are the refund/dispute paper trail (phone numbers, addresses, clinical detail).
|
||||
|
||||
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
|
||||
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
|
||||
@@ -577,7 +600,7 @@ Service registration is composed from per-layer extension methods (each project'
|
||||
|
||||
```
|
||||
builder.ValidateRequiredSecrets() // refinement-phase-5: fail fast on missing/placeholder DB + crypto secrets
|
||||
ConfigureHealthChecks() · SetupOpenTelemetry()
|
||||
ConfigureHealthChecks() · SetupOpenTelemetry() // refinement-phase-9: live/ready health split + object-storage probe; one OTel stack (metrics + tracing, opt-in OTLP)
|
||||
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
|
||||
RegisterIdentityServices(…, requireHttpsMetadata) // Identity, JWT/JWE (RequireHttpsMetadata on outside Dev/Testing), ICurrentUser
|
||||
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories, the IRecurringJob crons + RecurringJobSchedulerHostedService (refinement-phase-7)
|
||||
@@ -587,7 +610,7 @@ AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigi
|
||||
AddForwardedHeadersConfiguration(config) // refinement-phase-5: trust ForwardedHeaders:KnownProxies/KnownNetworks so the rate limiter sees the real client IP behind a proxy
|
||||
AddRateLimitingPolicies() // built-in rate limiter: per-resolved-IP global + named (otp/auth/sensitive/webhook)
|
||||
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
|
||||
ConfigureGrpcPluginServices()
|
||||
ConfigureGrpcPluginServices(builder.Environment) // refinement-phase-9: gRPC reflection registered only in Development
|
||||
// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each
|
||||
// OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development.
|
||||
```
|
||||
|
||||
@@ -36,19 +36,18 @@
|
||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageVersion Include="NSwag.AspNetCore" Version="14.7.1" />
|
||||
<PackageVersion Include="NuGet.Packaging" Version="7.6.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.15.1-beta.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
||||
<PackageVersion Include="Pluralize.NET" Version="1.0.2" />
|
||||
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
<PackageVersion Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Span" Version="3.1.0" />
|
||||
<PackageVersion Include="Serilog.Exceptions" Version="8.4.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.Elasticsearch" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.MSSqlServer" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.PeriodicBatching" Version="5.0.0" />
|
||||
<PackageVersion Include="System.Linq.Async" Version="7.0.1" />
|
||||
|
||||
@@ -110,7 +110,7 @@ TypeAdapterConfig.GlobalSettings.Scan(typeof(UserCreateCommand).Assembly,
|
||||
|
||||
#region Plugin Services Configuration
|
||||
|
||||
builder.Services.ConfigureGrpcPluginServices();
|
||||
builder.Services.ConfigureGrpcPluginServices(builder.Environment);
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Baya.Web.Plugins.Grpc.Services;
|
||||
|
||||
namespace Baya.Web.Plugins.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC plugin wiring. The plugin exposes the User service over HTTP/2; the client is HTTP/JSON only, so this is
|
||||
/// an internal/optional surface. <b>gRPC reflection is enabled only in Development</b> (refinement-phase-9 §9.5) —
|
||||
/// reflection advertises the full service schema and must not be reachable in deployed environments. The endpoint
|
||||
/// shares the mixed-protocol Kestrel listener (refinement-phase-5 set <c>Http1AndHttp2</c>), so ALPN negotiates
|
||||
/// HTTP/2 for gRPC clients while the REST API keeps HTTP/1.1 — no dedicated port is required.
|
||||
/// </summary>
|
||||
public static class GrpcPluginStartup
|
||||
{
|
||||
public static IServiceCollection ConfigureGrpcPluginServices(this IServiceCollection services)
|
||||
public static IServiceCollection ConfigureGrpcPluginServices(this IServiceCollection services, IHostEnvironment environment)
|
||||
{
|
||||
|
||||
|
||||
services.AddGrpc();
|
||||
services.AddGrpcReflection();
|
||||
|
||||
if (environment.IsDevelopment())
|
||||
services.AddGrpcReflection();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static void ConfigureGrpcPipeline(this WebApplication app)
|
||||
{
|
||||
|
||||
app.MapGrpcService<UserGrpcServices>();
|
||||
app.MapGrpcReflectionService();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
app.MapGrpcReflectionService();
|
||||
|
||||
app.MapGet("/GrpcUser", async context =>
|
||||
{
|
||||
@@ -29,4 +38,4 @@ public static class GrpcPluginStartup
|
||||
"Communication with this gRPC endpoint must be made through a gRPC client.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,17 @@ public interface IAuditLogger
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retention sweep (refinement-phase-9 §9.4): deletes audit rows past their retention window. Financial /
|
||||
/// compliance entity types (money + verification) keep a longer legal window than everyday rows. Processes the
|
||||
/// oldest rows first, capped at <paramref name="maxRowsPerRun"/> so a large backlog drains across runs without
|
||||
/// loading the whole table. Returns the number of rows deleted.
|
||||
/// </summary>
|
||||
ValueTask<int> PurgeExpiredAsync(
|
||||
int generalRetentionDays,
|
||||
int financialRetentionDays,
|
||||
IReadOnlyCollection<string> financialEntityTypes,
|
||||
int maxRowsPerRun,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,10 +7,10 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
<PackageReference Include="Serilog.Sinks.MSSqlServer" />
|
||||
<PackageReference Include="Serilog.Sinks.PeriodicBatching" />
|
||||
<PackageReference Include="Serilog.Exceptions" />
|
||||
<PackageReference Include="Serilog.Sinks.Elasticsearch" />
|
||||
<PackageReference Include="Serilog.Enrichers.Span" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
+25
-34
@@ -1,8 +1,9 @@
|
||||
using System.Data;
|
||||
using System.Data;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Serilog;
|
||||
using Serilog.Enrichers.Span;
|
||||
using Serilog.Events;
|
||||
using Serilog.Exceptions;
|
||||
using Serilog.Formatting.Json;
|
||||
using Serilog.Sinks.MSSqlServer;
|
||||
@@ -13,22 +14,29 @@ public static class LoggingConfiguration
|
||||
{
|
||||
public static Action<HostBuilderContext, LoggerConfiguration> ConfigureLogger => (context, configuration) =>
|
||||
{
|
||||
#region Enriching Logger Context
|
||||
#region Level & enrichment
|
||||
|
||||
var env = context.HostingEnvironment;
|
||||
|
||||
|
||||
configuration.Enrich.FromLogContext()
|
||||
// refinement-phase-9 §9.3: deployed environments log Information+ (previously Warning+, which silently
|
||||
// dropped every Information-level audit trail — logins, money-operation context). Framework categories
|
||||
// are held at Warning so raising the floor doesn't flood the sink with ASP.NET/EF request noise.
|
||||
// No PII/secrets are ever logged: the mock SMS sender no longer logs the OTP code, and clinical text /
|
||||
// IBANs / phone numbers are encrypted or masked before they reach any handler that logs.
|
||||
configuration
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithProperty("ApplicationName", env.ApplicationName)
|
||||
.Enrich.WithProperty("Environment", env.EnvironmentName)
|
||||
.Enrich.WithSpan()
|
||||
.Enrich.WithExceptionDetails();
|
||||
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
var columnOpts = new ColumnOptions();
|
||||
columnOpts.Store.Remove(StandardColumn.Properties);
|
||||
columnOpts.Store.Add(StandardColumn.LogEvent);
|
||||
@@ -39,36 +47,19 @@ public static class LoggingConfiguration
|
||||
// Development and Testing (WebApplicationFactory) log locally; the SQL sink is for deployed envs.
|
||||
if (!context.HostingEnvironment.IsDevelopment() && !context.HostingEnvironment.IsEnvironment("Testing"))
|
||||
{
|
||||
// Retention for the `log.LogEvents` table is an ops/DBA responsibility (a scheduled purge or a
|
||||
// partitioned/rolling table on Baya_Logs) — distinct from application data. Set an OpenTelemetry:Otlp
|
||||
// collector (see OpenTelemetryConfigurations) if you prefer to ship logs off-box instead of the SQL sink.
|
||||
configuration.WriteTo
|
||||
.MSSqlServer(
|
||||
connectionString: context.Configuration.GetConnectionString("logDb"),
|
||||
sinkOptions: new MSSqlServerSinkOptions { TableName = "LogEvents", AutoCreateSqlTable = true, SchemaName = "log",AutoCreateSqlDatabase = true})
|
||||
.MinimumLevel.Warning();
|
||||
|
||||
sinkOptions: new MSSqlServerSinkOptions { TableName = "LogEvents", AutoCreateSqlTable = true, SchemaName = "log", AutoCreateSqlDatabase = true },
|
||||
columnOptions: columnOpts);
|
||||
}
|
||||
|
||||
else{
|
||||
configuration.WriteTo.Console().MinimumLevel.Information();
|
||||
configuration.WriteTo.File(new JsonFormatter(), "logs/log.json").MinimumLevel.Information();
|
||||
else
|
||||
{
|
||||
configuration.WriteTo.Console();
|
||||
configuration.WriteTo.File(new JsonFormatter(), "logs/log.json");
|
||||
}
|
||||
|
||||
#region ElasticSearch Configuration. UnComment if Needed
|
||||
|
||||
|
||||
//var elasticUrl = context.Configuration.GetValue<string>("Logging:ElasticUrl");
|
||||
|
||||
//if (!string.IsNullOrEmpty(elasticUrl))
|
||||
//{
|
||||
// configuration.WriteTo.Elasticsearch(
|
||||
// new ElasticsearchSinkOptions(new Uri(elasticUrl))
|
||||
// {
|
||||
// AutoRegisterTemplate = true,
|
||||
// AutoRegisterTemplateVersion = AutoRegisterTemplateVersion.ESv7,
|
||||
// IndexFormat = "web-logs-{0:yyyy.MM.dd}",
|
||||
// MinimumLogEventLevel = LogEventLevel.Debug
|
||||
// });
|
||||
//}
|
||||
|
||||
#endregion
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+8
-6
@@ -4,22 +4,24 @@ using Microsoft.Extensions.Logging;
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Mock <see cref="ISmsSender"/>: "delivers" by logging. The OTP code is written to the log so a
|
||||
/// developer can complete the login flow; the phone number is never logged in full (PII policy) —
|
||||
/// only its last four digits. The real implementation swaps to an Iranian SMS gateway
|
||||
/// (Kavenegar/Ghasedak/SMS.ir) behind the same interface via a registration change.
|
||||
/// Mock <see cref="ISmsSender"/>: "delivers" by logging. The OTP <b>code is never logged</b> (refinement-phase-9
|
||||
/// §9.3 — no secrets/PII in logs, in any environment): a developer retrieves it from the Development-only
|
||||
/// <c>GET /api/v1/dev/last_otp/{phone}</c> helper (the <c>DevCapturingSmsSender</c> decorator), never the log.
|
||||
/// The phone number is logged only as its last four digits. The real implementation swaps to an Iranian SMS
|
||||
/// gateway (Kavenegar/Ghasedak/SMS.ir) behind the same interface via a registration change.
|
||||
/// </summary>
|
||||
public sealed class LoggingSmsSender(ILogger<LoggingSmsSender> logger) : ISmsSender
|
||||
{
|
||||
public Task SendOtpAsync(string phone, string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogWarning("MOCK SMS — OTP code {OtpCode} for phone ending in {PhoneTail}", code, Tail(phone));
|
||||
// Deliberately does NOT log the OTP code — it is a login secret. Retrieve it via /dev/last_otp in Development.
|
||||
logger.LogInformation("MOCK SMS — OTP issued to phone ending in {PhoneTail}", Tail(phone));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendAsync(string phone, string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogWarning("MOCK SMS — message to phone ending in {PhoneTail}: {Message}", Tail(phone), message);
|
||||
logger.LogInformation("MOCK SMS — message to phone ending in {PhoneTail}: {Message}", Tail(phone), message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -10,13 +10,17 @@
|
||||
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
<PackageReference Include="prometheus-net" />
|
||||
<PackageReference Include="prometheus-net.AspNetCore" />
|
||||
<PackageReference Include="prometheus-net.AspNetCore.HealthChecks" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The object-storage readiness probe resolves the IObjectStorage seam (Application contract). -->
|
||||
<ProjectReference Include="..\..\Core\Baya.Application\Baya.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+53
-11
@@ -1,38 +1,80 @@
|
||||
using System.Net.Security;
|
||||
using Baya.Infrastructure.Monitoring.HealthChecks;
|
||||
using HealthChecks.UI.Client;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Prometheus;
|
||||
|
||||
namespace Baya.Infrastructure.Monitoring.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Health checks split into <b>liveness</b> (is the process up?) and <b>readiness</b> (can it serve traffic —
|
||||
/// are its dependencies reachable?), refinement-phase-9 §9.2. An orchestrator restarts on a failed liveness
|
||||
/// probe but only routes traffic on a passing readiness probe, so a broken log DB or object storage takes the
|
||||
/// instance out of rotation without a restart loop.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>/healthz/live</c> — process only (always healthy while the host runs); never touches a dependency.</item>
|
||||
/// <item><c>/healthz/ready</c> — the app DB, the log DB (deployed only), and the object-storage write probe.</item>
|
||||
/// <item><c>/HealthCheck</c> — the aggregate (every check), retained for backward compatibility.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static class HealthCheckConfigurations
|
||||
{
|
||||
private const string Ready = "ready";
|
||||
private const string Live = "live";
|
||||
|
||||
public static WebApplicationBuilder ConfigureHealthChecks(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddSqlServer(builder.Configuration.GetConnectionString("SqlServer")!, name: "SQL Server")
|
||||
.ForwardToPrometheus();
|
||||
var checks = builder.Services.AddHealthChecks();
|
||||
|
||||
var currentUrl = builder.Configuration["ASPNETCORE_URLS"]?.Split(';')[0].Replace("+", "localhost");
|
||||
|
||||
// Liveness: the process answers. Deliberately dependency-free so a dependency outage never restarts a
|
||||
// healthy instance — that is readiness's job.
|
||||
checks.AddCheck("self", () => HealthCheckResult.Healthy(), tags: [Live]);
|
||||
|
||||
// Readiness: the system of record.
|
||||
checks.AddSqlServer(builder.Configuration.GetConnectionString("SqlServer")!, name: "sql-app", tags: [Ready]);
|
||||
|
||||
// The Serilog SQL sink only runs in deployed environments (Development/Testing log to console/file), and
|
||||
// its connection string is a placeholder there — so only gate readiness on it where it is actually used.
|
||||
if (!builder.Environment.IsDevelopment() && !builder.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
var logDb = builder.Configuration.GetConnectionString("logDb");
|
||||
if (!string.IsNullOrWhiteSpace(logDb))
|
||||
checks.AddSqlServer(logDb, name: "sql-logs", tags: [Ready]);
|
||||
}
|
||||
|
||||
// Object storage (verification docs, avatars, invoice PDFs) — a real write round-trip.
|
||||
checks.AddCheck<ObjectStorageWriteHealthCheck>("object-storage", tags: [Ready]);
|
||||
|
||||
// Redis (ICacheService / IDistributedLock) is single-process today (refinement-phase-7); when it becomes a
|
||||
// real external dependency for a multi-instance deployment, add a "redis" check tagged Ready here.
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static WebApplication UseHealthChecks(this WebApplication app)
|
||||
{
|
||||
app.MapHealthChecks("/healthz/live", new HealthCheckOptions
|
||||
{
|
||||
Predicate = check => check.Tags.Contains(Live),
|
||||
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
|
||||
}).ShortCircuit();
|
||||
|
||||
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
|
||||
{
|
||||
Predicate = check => check.Tags.Contains(Ready),
|
||||
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
|
||||
}).ShortCircuit();
|
||||
|
||||
// Aggregate endpoint (all checks) — retained for backward compatibility with existing probes/dashboards.
|
||||
app.MapHealthChecks("/HealthCheck", new HealthCheckOptions
|
||||
{
|
||||
Predicate = _ => true,
|
||||
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
|
||||
}).ShortCircuit().DisableHttpMetrics();
|
||||
|
||||
|
||||
}).ShortCircuit();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-4
@@ -1,14 +1,35 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Baya.Infrastructure.Monitoring.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// The single OpenTelemetry stack (refinement-phase-9, §9.1). Metrics and traces share one resource
|
||||
/// (<c>service.name = Baya.Web.Api</c>). Metrics are scraped by Prometheus (the <c>/metrics</c> endpoint,
|
||||
/// wired in <see cref="PrometheusMetricsConfigurations"/>); traces cover ASP.NET Core requests and EF Core
|
||||
/// commands so a cross-service money flow (webhook → confirm → ledger) can be followed end to end. The
|
||||
/// request's trace id is the <c>ApiResult.RequestId</c> the client sees, so a support ticket maps 1:1 to a
|
||||
/// trace.
|
||||
/// <para>The prior duplicate prometheus-net stack was removed — OpenTelemetry is now the only metrics source.</para>
|
||||
/// <para>OTLP export (traces + metrics) is <b>opt-in</b>: it is wired only when <c>OpenTelemetry:Otlp:Endpoint</c>
|
||||
/// is configured, so an MVP deployment with Prometheus alone runs unchanged and no exporter spams connection
|
||||
/// errors against an absent collector.</para>
|
||||
/// </summary>
|
||||
public static class OpenTelemetryConfigurations
|
||||
{
|
||||
public const string ServiceName = "Baya.Web.Api";
|
||||
|
||||
public static WebApplicationBuilder SetupOpenTelemetry(this WebApplicationBuilder builder)
|
||||
{
|
||||
var otlpEndpoint = builder.Configuration["OpenTelemetry:Otlp:Endpoint"];
|
||||
var hasOtlp = !string.IsNullOrWhiteSpace(otlpEndpoint);
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(resource => resource.AddService(ServiceName))
|
||||
.WithMetrics(metricsBuilder =>
|
||||
{
|
||||
metricsBuilder.AddRuntimeInstrumentation()
|
||||
@@ -17,12 +38,26 @@ public static class OpenTelemetryConfigurations
|
||||
, "Microsoft.AspNetCore.Server.Kestrel"
|
||||
, "System.Net.Http"
|
||||
, "Baya.Web.Api"
|
||||
, "ControllerMetrics")
|
||||
, "ControllerMetrics"
|
||||
// The mediator request-duration histogram (MetricsBehaviour) — captured here so the one
|
||||
// metrics stack actually exports it (the removed prometheus-net stack never did).
|
||||
, "mediator_meter")
|
||||
.AddPrometheusExporter();
|
||||
|
||||
if (hasOtlp)
|
||||
metricsBuilder.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint!));
|
||||
})
|
||||
.WithTracing(tracingBuilder =>
|
||||
{
|
||||
tracingBuilder.AddAspNetCoreInstrumentation()
|
||||
.AddEntityFrameworkCoreInstrumentation();
|
||||
|
||||
if (hasOtlp)
|
||||
tracingBuilder.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint!));
|
||||
});
|
||||
|
||||
builder.Services.AddMetrics();
|
||||
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-6
@@ -1,15 +1,17 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Prometheus;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace Baya.Infrastructure.Monitoring.Configurations;
|
||||
|
||||
public static class PrometheusMetricsConfigurations
|
||||
{
|
||||
/// <summary>
|
||||
/// Exposes the single OpenTelemetry metrics stack for Prometheus scraping at <c>/metrics</c>
|
||||
/// (refinement-phase-9, §9.1 — replaces the removed prometheus-net <c>UseMetricServer</c>/
|
||||
/// <c>UseHttpMetrics</c> stack; HTTP request metrics now come from the OTel ASP.NET Core instrumentation).
|
||||
/// </summary>
|
||||
public static WebApplication UseMetrics(this WebApplication app)
|
||||
{
|
||||
|
||||
app.UseMetricServer();
|
||||
app.UseHttpMetrics();
|
||||
app.UseOpenTelemetryPrometheusScrapingEndpoint();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#nullable enable
|
||||
using System.Text;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace Baya.Infrastructure.Monitoring.HealthChecks;
|
||||
|
||||
/// <summary>
|
||||
/// Readiness probe for the <see cref="IObjectStorage"/> seam (verification documents, avatars, invoice PDFs):
|
||||
/// a deploy can pass a DB-only health check while uploads are broken. This does a real round-trip —
|
||||
/// write a tiny probe blob, read it back, delete it — so a misconfigured bucket / unreachable endpoint /
|
||||
/// missing write permission surfaces as <see cref="HealthStatus.Unhealthy"/> rather than a first-user 500.
|
||||
/// The probe key is namespaced and deleted every run, so it never accumulates.
|
||||
/// </summary>
|
||||
internal sealed class ObjectStorageWriteHealthCheck(IObjectStorage objectStorage) : IHealthCheck
|
||||
{
|
||||
private const string ProbeKey = "healthz/object-storage-probe";
|
||||
private static readonly byte[] ProbePayload = Encoding.UTF8.GetBytes("ok");
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using (var writeStream = new MemoryStream(ProbePayload, writable: false))
|
||||
await objectStorage.PutAsync(ProbeKey, writeStream, "text/plain", cancellationToken);
|
||||
|
||||
await using var readStream = await objectStorage.GetAsync(ProbeKey, cancellationToken);
|
||||
if (readStream is null)
|
||||
return HealthCheckResult.Unhealthy("Object storage write succeeded but the probe blob was not readable.");
|
||||
|
||||
await objectStorage.DeleteAsync(ProbeKey, cancellationToken);
|
||||
return HealthCheckResult.Healthy();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Object storage round-trip failed.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,5 +179,15 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
|
||||
{
|
||||
builder.Property(c => c.SettlementIban).HasConversion(encrypted);
|
||||
});
|
||||
|
||||
// refinement-phase-9 §9.5: ticket message bodies are the refund/dispute paper trail — users type phone
|
||||
// numbers, addresses and clinical detail into them — so they are encrypted at rest through the same seam.
|
||||
// The plaintext-length limit (4000) stays a boundary-validation rule; the stored ciphertext column is
|
||||
// widened to nvarchar(max) in TicketMessageConfig. Body is never a search/filter predicate (the admin
|
||||
// thread read decrypts per row), so losing SQL-searchability on it is an accepted trade-off.
|
||||
modelBuilder.Entity<Baya.Domain.Entities.Messaging.TicketMessage>(builder =>
|
||||
{
|
||||
builder.Property(m => m.Body).HasConversion(encrypted);
|
||||
});
|
||||
}
|
||||
}
|
||||
+5
@@ -52,6 +52,11 @@ internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformCo
|
||||
(21, "refund_assume_nurse_paid", "false", ConfigDataType.Bool, "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation."),
|
||||
(22, "payout_satna_threshold_irr", "1000000000", ConfigDataType.Decimal, "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13)."),
|
||||
(23, "require_bnpl_settlement_for_payout", "false", ConfigDataType.Bool, "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard)."),
|
||||
// refinement-phase-9 §9.4: two-tier audit-log retention. Financial/verification rows keep a long legal
|
||||
// window; everyday rows a shorter one; the retention sweep runs on the cadence key.
|
||||
(24, "audit_retention_general_days", "730", ConfigDataType.Int, "Retention (days) for everyday audit rows before the retention sweep deletes them (~2 years)."),
|
||||
(25, "audit_retention_financial_days", "2555", ConfigDataType.Int, "Retention (days) for financial/verification audit rows (refunds/clawbacks/payouts/verification/config/partner-center) — the long legal window (~7 years)."),
|
||||
(26, "audit_retention_scan_cadence_hours", "24", ConfigDataType.Int, "Hours between audit-log retention sweeps."),
|
||||
];
|
||||
|
||||
return rows
|
||||
|
||||
+4
-1
@@ -15,7 +15,10 @@ internal sealed class TicketMessageConfig : IEntityTypeConfiguration<TicketMessa
|
||||
{
|
||||
builder.ToTable("TicketMessages", "messaging");
|
||||
|
||||
builder.Property(m => m.Body).HasMaxLength(4000).IsRequired();
|
||||
// Body is encrypted at rest (converter wired in ApplicationDbContext, refinement-phase-9 §9.5), so the
|
||||
// stored column holds base64 ciphertext (longer than the plaintext) — nvarchar(max), no length cap here.
|
||||
// The 4000-char plaintext limit is enforced at the boundary (Open/PostMessage validators).
|
||||
builder.Property(m => m.Body).IsRequired();
|
||||
builder.Property(m => m.IsInternal).HasDefaultValue(false);
|
||||
builder.Property(m => m.ClientMessageId).HasMaxLength(100);
|
||||
|
||||
|
||||
+6072
File diff suppressed because it is too large
Load Diff
+70
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RefinementPhase9TicketBodyEncryptionAndAuditRetention : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Body",
|
||||
schema: "messaging",
|
||||
table: "TicketMessages",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(4000)",
|
||||
oldMaxLength: 4000);
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
schema: "ops",
|
||||
table: "PlatformConfigs",
|
||||
columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 24L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Retention (days) for everyday audit rows before the retention sweep deletes them (~2 years).", "audit_retention_general_days", null, null, "730" },
|
||||
{ 25L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Retention (days) for financial/verification audit rows (refunds/clawbacks/payouts/verification/config/partner-center) — the long legal window (~7 years).", "audit_retention_financial_days", null, null, "2555" },
|
||||
{ 26L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Hours between audit-log retention sweeps.", "audit_retention_scan_cadence_hours", null, null, "24" }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
schema: "ops",
|
||||
table: "PlatformConfigs",
|
||||
keyColumn: "Id",
|
||||
keyValue: 24L);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
schema: "ops",
|
||||
table: "PlatformConfigs",
|
||||
keyColumn: "Id",
|
||||
keyValue: 25L);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
schema: "ops",
|
||||
table: "PlatformConfigs",
|
||||
keyColumn: "Id",
|
||||
keyValue: 26L);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Body",
|
||||
schema: "messaging",
|
||||
table: "TicketMessages",
|
||||
type: "nvarchar(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "nvarchar(max)");
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-2
@@ -1300,6 +1300,33 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
Description = "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard).",
|
||||
Key = "require_bnpl_settlement_for_payout",
|
||||
Value = "false"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 24L,
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
DataType = "int",
|
||||
Description = "Retention (days) for everyday audit rows before the retention sweep deletes them (~2 years).",
|
||||
Key = "audit_retention_general_days",
|
||||
Value = "730"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 25L,
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
DataType = "int",
|
||||
Description = "Retention (days) for financial/verification audit rows (refunds/clawbacks/payouts/verification/config/partner-center) — the long legal window (~7 years).",
|
||||
Key = "audit_retention_financial_days",
|
||||
Value = "2555"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 26L,
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
DataType = "int",
|
||||
Description = "Hours between audit-log retention sweeps.",
|
||||
Key = "audit_retention_scan_cadence_hours",
|
||||
Value = "24"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3035,8 +3062,7 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("nvarchar(4000)");
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ClientMessageId")
|
||||
.HasMaxLength(100)
|
||||
|
||||
+2
@@ -72,6 +72,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<IRecurringJob, WeeklyPayoutGenerationJob>();
|
||||
// refinement-phase-8 (6.5): walk pending/submitted سامانه مودیان invoices toward their registered reference.
|
||||
services.AddSingleton<IRecurringJob, MoadianReconciliationJob>();
|
||||
// refinement-phase-9 (§9.4): two-tier retention sweep over the append-only audit_logs table.
|
||||
services.AddSingleton<IRecurringJob, AuditLogRetentionJob>();
|
||||
services.AddHostedService<RecurringJobSchedulerHostedService>();
|
||||
|
||||
// Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside
|
||||
|
||||
+33
@@ -77,4 +77,37 @@ internal sealed class AuditLogger(
|
||||
|
||||
return new PagedResult<AuditLogDto>(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
public async ValueTask<int> PurgeExpiredAsync(
|
||||
int generalRetentionDays,
|
||||
int financialRetentionDays,
|
||||
IReadOnlyCollection<string> financialEntityTypes,
|
||||
int maxRowsPerRun,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = dateTimeProvider.UtcNow;
|
||||
var generalCutoff = now.AddDays(-generalRetentionDays);
|
||||
var financialCutoff = now.AddDays(-financialRetentionDays);
|
||||
|
||||
// Oldest-first (Id is monotonic with OccurredAt), capped. The age comparison is done in memory — the
|
||||
// SQLite test provider can't translate a DateTimeOffset predicate — and the delete is a single id-keyed
|
||||
// statement that translates on every provider. A financial/compliance row survives until the longer window.
|
||||
var candidates = await db.Set<AuditLog>().AsNoTracking()
|
||||
.OrderBy(a => a.Id)
|
||||
.Select(a => new { a.Id, a.EntityType, a.OccurredAt })
|
||||
.Take(maxRowsPerRun)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var expiredIds = candidates
|
||||
.Where(a => a.OccurredAt < (financialEntityTypes.Contains(a.EntityType) ? financialCutoff : generalCutoff))
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
|
||||
if (expiredIds.Count == 0)
|
||||
return 0;
|
||||
|
||||
return await db.Set<AuditLog>()
|
||||
.Where(a => expiredIds.Contains(a.Id))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Baya.Application.Contracts.Audit;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Retention/archival policy for the append-only <c>ops.AuditLogs</c> table (refinement-phase-9 §9.4). The audit
|
||||
/// trail is never pruned by the application except here, on a schedule, and even then a two-tier policy protects
|
||||
/// the legally-sensitive rows: <b>financial & verification</b> entity types (refunds, clawbacks, payouts,
|
||||
/// payout batches, verification decisions, config changes, partner centers) keep a long window
|
||||
/// (<c>audit_retention_financial_days</c>, default ~7 years); everyday operational rows keep a shorter one
|
||||
/// (<c>audit_retention_general_days</c>, default ~2 years). Idempotent: a re-run simply finds nothing new to
|
||||
/// delete. Runs on the <c>audit_retention_scan_cadence_hours</c> cadence.
|
||||
/// </summary>
|
||||
internal sealed class AuditLogRetentionJob(ILogger<AuditLogRetentionJob> logger) : IRecurringJob
|
||||
{
|
||||
// Bound the per-run working set so a large backlog drains across successive runs without loading the whole table.
|
||||
private const int MaxRowsPerRun = 20_000;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>IAuditable</c> entity types whose audit rows carry money- or verification-legal weight. Their
|
||||
/// <c>EntityType</c> is the CLR type name written by <c>AuditFieldInterceptor</c>. Everything else uses the
|
||||
/// shorter general window.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyCollection<string> FinancialEntityTypes =
|
||||
[
|
||||
"Refund",
|
||||
"NurseClawback",
|
||||
"NursePayout",
|
||||
"NursePayoutBatch",
|
||||
"NurseVerification",
|
||||
"PlatformConfig",
|
||||
"PartnerCenter"
|
||||
];
|
||||
|
||||
public string Name => "audit_log_retention";
|
||||
|
||||
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var config = services.GetRequiredService<IPlatformConfig>();
|
||||
var hours = await config.GetConfig<int>("audit_retention_scan_cadence_hours", cancellationToken);
|
||||
return TimeSpan.FromHours(hours);
|
||||
}
|
||||
|
||||
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var config = services.GetRequiredService<IPlatformConfig>();
|
||||
var audit = services.GetRequiredService<IAuditLogger>();
|
||||
|
||||
var generalDays = await config.GetConfig<int>("audit_retention_general_days", cancellationToken);
|
||||
var financialDays = await config.GetConfig<int>("audit_retention_financial_days", cancellationToken);
|
||||
|
||||
var deleted = await audit.PurgeExpiredAsync(
|
||||
generalDays, financialDays, FinancialEntityTypes, MaxRowsPerRun, cancellationToken);
|
||||
|
||||
if (deleted > 0)
|
||||
logger.LogInformation(
|
||||
"Audit retention purged {Count} audit row(s) (general>{GeneralDays}d, financial>{FinancialDays}d)",
|
||||
deleted, generalDays, financialDays);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Net;
|
||||
|
||||
namespace Baya.Test.Api;
|
||||
|
||||
/// <summary>
|
||||
/// refinement-phase-9 §9.2: liveness is split from readiness. <c>/healthz/live</c> reports only the process
|
||||
/// (dependency-free) so a dependency outage never triggers a restart loop; it must answer healthy even in the
|
||||
/// test host, whose readiness dependencies (a real SQL Server) are absent.
|
||||
/// </summary>
|
||||
public class HealthCheckApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||
{
|
||||
[Fact]
|
||||
public async Task Liveness_IsHealthy_WithoutTouchingDependencies()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/healthz/live");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Domain.Entities.Audit;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Baya.Infrastructure.Persistence.Services.Audit;
|
||||
using Baya.Tests.Setup.Setups;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// refinement-phase-9 §9.4: the two-tier audit-log retention sweep. Everyday rows are deleted past the general
|
||||
/// window; financial/verification rows survive until the longer legal window. The sweep is idempotent.
|
||||
/// </summary>
|
||||
public sealed class AuditLogRetentionTests : IDisposable
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
|
||||
private const int GeneralDays = 730;
|
||||
private const int FinancialDays = 2555;
|
||||
private static readonly string[] FinancialTypes = ["Refund", "NursePayout", "NurseVerification", "PlatformConfig"];
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly AuditLogger _audit;
|
||||
|
||||
public AuditLogRetentionTests()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
|
||||
_db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
|
||||
_db.Database.EnsureCreated();
|
||||
|
||||
var clock = Substitute.For<IDateTimeProvider>();
|
||||
clock.UtcNow.Returns(Now);
|
||||
_audit = new AuditLogger(_db, Substitute.For<ICurrentUser>(), clock);
|
||||
}
|
||||
|
||||
private void Seed(string entityType, int ageDays)
|
||||
{
|
||||
_db.Set<AuditLog>().Add(new AuditLog
|
||||
{
|
||||
EntityType = entityType,
|
||||
EntityId = $"{entityType}-{ageDays}",
|
||||
Action = AuditAction.Updated,
|
||||
OccurredAt = Now.AddDays(-ageDays)
|
||||
});
|
||||
_db.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Purge_deletes_expired_general_rows_and_keeps_recent_and_financial()
|
||||
{
|
||||
Seed("SomeEntity", ageDays: 800); // general, past 730 → deleted
|
||||
Seed("SomeEntity", ageDays: 700); // general, within 730 → kept
|
||||
Seed("Refund", ageDays: 800); // financial, within 2555 → kept
|
||||
Seed("NursePayout", ageDays: 3000); // financial, past 2555 → deleted
|
||||
|
||||
var deleted = await _audit.PurgeExpiredAsync(GeneralDays, FinancialDays, FinancialTypes, maxRowsPerRun: 1000, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, deleted);
|
||||
var survivors = await _db.Set<AuditLog>().AsNoTracking().Select(a => a.EntityId).ToListAsync();
|
||||
Assert.Equal(2, survivors.Count);
|
||||
Assert.Contains("SomeEntity-700", survivors);
|
||||
Assert.Contains("Refund-800", survivors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Purge_is_idempotent_when_nothing_is_expired()
|
||||
{
|
||||
Seed("SomeEntity", ageDays: 10);
|
||||
Seed("Refund", ageDays: 100);
|
||||
|
||||
var first = await _audit.PurgeExpiredAsync(GeneralDays, FinancialDays, FinancialTypes, maxRowsPerRun: 1000, CancellationToken.None);
|
||||
var second = await _audit.PurgeExpiredAsync(GeneralDays, FinancialDays, FinancialTypes, maxRowsPerRun: 1000, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, first);
|
||||
Assert.Equal(0, second);
|
||||
Assert.Equal(2, await _db.Set<AuditLog>().CountAsync());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Baya.Domain.Entities.Messaging;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Baya.Tests.Setup.Setups;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Test.Foundation.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// refinement-phase-9 §9.5: ticket message bodies (the refund/dispute paper trail — phone numbers, addresses,
|
||||
/// clinical detail) are encrypted at rest through the <c>IFieldEncryptor</c> seam. The stored column holds
|
||||
/// ciphertext, never the plaintext; a normal read still returns the plaintext (EF applies the converter).
|
||||
/// </summary>
|
||||
public sealed class TicketMessageEncryptionTests : IDisposable
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly ApplicationDbContext _db;
|
||||
|
||||
public TicketMessageEncryptionTests()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
|
||||
_db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
|
||||
_db.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
private long SeedMessage(string body)
|
||||
{
|
||||
var sender = new User { UserName = "u1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
|
||||
_db.Users.Add(sender);
|
||||
_db.SaveChanges();
|
||||
|
||||
var ticket = new Ticket { ReferenceCode = "TKT-ENC001", Category = TicketCategory.Support, OpenedById = sender.Id };
|
||||
_db.Set<Ticket>().Add(ticket);
|
||||
_db.SaveChanges();
|
||||
|
||||
var message = new TicketMessage { TicketId = ticket.Id, SenderId = sender.Id, Body = body, IsInternal = false, SentAt = Now };
|
||||
_db.Set<TicketMessage>().Add(message);
|
||||
_db.SaveChanges();
|
||||
return message.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Body_is_stored_encrypted_at_rest()
|
||||
{
|
||||
const string plaintext = "patient at 09121234567, unit 4B — chest pain";
|
||||
var id = SeedMessage(plaintext);
|
||||
|
||||
await using var command = _connection.CreateCommand();
|
||||
command.CommandText = "SELECT Body FROM TicketMessages WHERE Id = $id";
|
||||
command.Parameters.AddWithValue("$id", id);
|
||||
var stored = (string?)await command.ExecuteScalarAsync();
|
||||
|
||||
Assert.NotNull(stored);
|
||||
Assert.NotEqual(plaintext, stored);
|
||||
Assert.Equal(TestFieldEncryptor.Instance.Encrypt(plaintext), stored);
|
||||
Assert.DoesNotContain("09121234567", stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Body_round_trips_to_plaintext_on_read()
|
||||
{
|
||||
const string plaintext = "please confirm the visit address";
|
||||
var id = SeedMessage(plaintext);
|
||||
|
||||
// Fresh context so the value comes from the store, not the change tracker.
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
|
||||
await using var freshDb = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
|
||||
var body = await freshDb.Set<TicketMessage>().Where(m => m.Id == id).Select(m => m.Body).SingleAsync();
|
||||
|
||||
Assert.Equal(plaintext, body);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user