backend phase 1: config, reference & platform signals

Lay the cross-cutting platform backbone every later phase reads from. Adds
the first marketplace EF migration baseline (new `ops` schema) and the
mechanisms b2..b15 reuse: typed runtime config, an append-only audit trail,
an analytics event log, the holiday/bank-closure calendar, in-app
notifications, and the internal support-alert worklist.

Schema & migration
- New `ops` schema + migration InitialMarketplaceBaseline with 6 tables:
  PlatformConfigs (IAuditable), AuditLogs (append-only), SystemEvents,
  IranianHolidays, Notifications, SupportAlerts — with indexes/uniques and
  FKs to usr.Users. Seeded 12 config keys + 7 sample holidays via HasData.

Domain / Application
- IAuditable marker + [AuditRedacted] attribute; entities + string-code
  constant holders (config data_type, holiday type, alert type/severity/status).
- Facade contracts: IPlatformConfig, IHolidayCalendar, IAnalyticsSink,
  IAuditLogger, INotificationService, ISupportAlertService; DTOs +
  PagedResult<T>; evolved the INotificationDispatcher.Notification record to
  carry Type + DataJson; Pagination helper.
- 14 CQRS commands/queries (+ validators) wiring the endpoints to the facades.

Infrastructure
- DB-backed facade implementations in Persistence/Services/; real in-app
  INotificationDispatcher (removes the b0 log stub); notification-retention
  hosted service (purge is_read=1 AND age>90d).
- Extended AuditFieldInterceptor to also append an old/new-diff audit_logs row
  for every IAuditable change in the same transaction (PII redacted).
- Registered all facades + hosted service in AddPersistenceServices; removed
  the dispatcher registration from AddCrossCuttingSeams.

API
- 5 controllers: admin PlatformConfig/Holidays/Audit/SupportAlerts
  ([Authorize(DynamicPermission)]) + current-user Notifications ([Authorize]),
  all tenant-scoped and paginated. 16 Swagger paths total.

Money-correctness & safety rules honoured
- Config read at compute time (cached, parsed by data_type), never hardcoded;
  every config change is audited in the same transaction; audit_logs is
  append-only (no update/delete path); support alerts are admin-only;
  notifications are tenant-scoped; analytics is fire-and-forget.

Tests & docs
- 18 new foundation tests over in-memory SQLite (config typing + audit,
  holidays, notifications + tenancy + retention, support alerts, analytics);
  build clean (0 new code warnings), 22 tests green; migration applied to the
  dev DB and swagger.v1.json refreshed.
- Updated server Project map + CONVENTIONS, product data-model doc 12 (seeded
  config defaults), config-reference contract, mock registry, backend handoff/
  STATUS/report.

Follow-ups: add FK constraints for SupportAlerts.BookingId (b9) and ReviewId
(b14) when those tables land.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-02 01:18:00 +03:30
parent aae1ce971f
commit 2f2aec61a2
99 changed files with 6172 additions and 52 deletions
@@ -0,0 +1,24 @@
using Baya.Domain.Entities.Analytics;
using Baya.Infrastructure.Persistence.Services.Analytics;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace Baya.Test.Foundation.Marketplace;
public sealed class AnalyticsSinkTests
{
[Fact]
public async Task Emit_InsertsSystemEventRow()
{
using var host = new OpsTestHost();
host.CurrentUser.UserId = await host.AddUserAsync("actor");
var sink = new AnalyticsSink(host.Db, host.CurrentUser, host.Clock, NullLogger<AnalyticsSink>.Instance);
await sink.EmitAsync("nurse_search_performed", new { query = "cardiac" });
var evt = await host.Db.Set<SystemEvent>().SingleAsync();
Assert.Equal("nurse_search_performed", evt.Name);
Assert.Equal(host.CurrentUser.UserId, evt.UserId);
Assert.Contains("cardiac", evt.PropsJson);
}
}
@@ -0,0 +1,56 @@
using Baya.Domain.Entities.Holidays;
using Baya.Infrastructure.Persistence.Services.Holidays;
namespace Baya.Test.Foundation.Marketplace;
public sealed class HolidayCalendarServiceTests
{
[Fact]
public async Task IsBankClosed_SeededBankClosedDate_True()
{
using var host = new OpsTestHost();
var calendar = new HolidayCalendarService(host.Db, host.Cache);
Assert.True(await calendar.IsBankClosed(new DateOnly(2026, 3, 21)));
}
[Fact]
public async Task IsHoliday_NonHoliday_False()
{
using var host = new OpsTestHost();
var calendar = new HolidayCalendarService(host.Db, host.Cache);
Assert.False(await calendar.IsHoliday(new DateOnly(2026, 5, 4)));
}
[Fact]
public async Task NextBusinessDay_FromHoliday_ReturnsOpenBankDay()
{
using var host = new OpsTestHost();
var calendar = new HolidayCalendarService(host.Db, host.Cache);
// The Nowruz block 2124 March is bank-closed; the answer is the first later open, non-Friday day.
var next = await calendar.NextBusinessDay(new DateOnly(2026, 3, 21));
Assert.True(next > new DateOnly(2026, 3, 24));
Assert.NotEqual(DayOfWeek.Friday, next.DayOfWeek);
Assert.False(await calendar.IsBankClosed(next));
}
[Fact]
public async Task Upsert_ThenDelete_RoundTrips()
{
using var host = new OpsTestHost();
host.CurrentUser.UserId = await host.AddUserAsync("admin");
var calendar = new HolidayCalendarService(host.Db, host.Cache);
var date = new DateOnly(2026, 9, 1);
await calendar.UpsertAsync(date, "روز آزمایشی", HolidayType.Official, true);
Assert.True(await calendar.IsHoliday(date));
Assert.True(await calendar.IsBankClosed(date));
Assert.True(await calendar.DeleteAsync(date));
Assert.False(await calendar.IsHoliday(date));
Assert.False(await calendar.DeleteAsync(date));
}
}
@@ -0,0 +1,79 @@
using Baya.Application.Contracts.Common;
using Baya.Infrastructure.Persistence.Services.Notifications;
namespace Baya.Test.Foundation.Marketplace;
public sealed class NotificationServiceTests
{
[Fact]
public async Task Dispatch_ThenList_UnreadFirst_Count_MarkRead()
{
using var host = new OpsTestHost();
var userId = await host.AddUserAsync("family");
var dispatcher = new InAppNotificationDispatcher(host.Db, host.Clock);
var service = new NotificationService(host.Db, host.Clock);
await dispatcher.DispatchAsync(new Notification(userId, "booking_confirmed", "Booking confirmed", DataJson: "{\"booking_id\":1}"));
var page = await service.ListMineAsync(userId, 1, 20);
var notification = Assert.Single(page.Items);
Assert.False(notification.IsRead);
Assert.Equal("booking_confirmed", notification.Type);
Assert.Equal(1, await service.GetUnreadCountAsync(userId));
Assert.True(await service.MarkReadAsync(userId, notification.Id));
Assert.Equal(0, await service.GetUnreadCountAsync(userId));
}
[Fact]
public async Task Notifications_AreTenantScoped()
{
using var host = new OpsTestHost();
var owner = await host.AddUserAsync("owner");
var other = await host.AddUserAsync("other");
var dispatcher = new InAppNotificationDispatcher(host.Db, host.Clock);
var service = new NotificationService(host.Db, host.Clock);
await dispatcher.DispatchAsync(new Notification(owner, "booking_confirmed", "For owner"));
Assert.Empty((await service.ListMineAsync(other, 1, 20)).Items);
Assert.Equal(0, await service.GetUnreadCountAsync(other));
// A different user cannot mark another user's notification read.
var ownerNotificationId = (await service.ListMineAsync(owner, 1, 20)).Items[0].Id;
Assert.False(await service.MarkReadAsync(other, ownerNotificationId));
}
[Fact]
public async Task PurgeOldRead_RemovesOnlyReadOlderThanWindow()
{
using var host = new OpsTestHost();
var userId = await host.AddUserAsync("family");
var dispatcher = new InAppNotificationDispatcher(host.Db, host.Clock);
var service = new NotificationService(host.Db, host.Clock);
var reference = host.Clock.UtcNow;
// Old + read → should be purged.
host.Clock.UtcNow = reference.AddDays(-100);
await dispatcher.DispatchAsync(new Notification(userId, "t", "old read"));
var oldReadId = (await service.ListMineAsync(userId, 1, 20)).Items[0].Id;
await service.MarkReadAsync(userId, oldReadId);
// Old + unread → must survive.
await dispatcher.DispatchAsync(new Notification(userId, "t", "old unread"));
// Recent + read → must survive.
host.Clock.UtcNow = reference;
await dispatcher.DispatchAsync(new Notification(userId, "t", "recent read"));
var recentReadId = (await service.ListMineAsync(userId, 1, 20)).Items.First(n => n.Title == "recent read").Id;
await service.MarkReadAsync(userId, recentReadId);
var removed = await service.PurgeOldReadAsync(90);
Assert.Equal(1, removed);
var remaining = (await service.ListMineAsync(userId, 1, 20)).Items;
Assert.Equal(2, remaining.Count);
Assert.DoesNotContain(remaining, n => n.Title == "old read");
}
}
@@ -0,0 +1,69 @@
using Baya.Application.Contracts.Common;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.CrossCutting.Seams;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Interceptors;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace Baya.Test.Foundation.Marketplace;
/// <summary>
/// Spins up a real <see cref="ApplicationDbContext"/> over an isolated in-memory SQLite database with the
/// audit interceptor wired and the marketplace seed applied (via <c>EnsureCreated</c>). Gives the
/// platform-signal services something faithful to run against, with a controllable clock and caller.
/// </summary>
internal sealed class OpsTestHost : IDisposable
{
private readonly SqliteConnection _connection;
public ApplicationDbContext Db { get; }
public TestClock Clock { get; } = new();
public TestCurrentUser CurrentUser { get; } = new();
public ICacheService Cache { get; } = new MemoryCacheService(new MemoryCache(new MemoryCacheOptions()));
public OpsTestHost()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
Clock.UtcNow = new DateTimeOffset(2026, 7, 1, 12, 0, 0, TimeSpan.Zero);
var interceptor = new AuditFieldInterceptor(CurrentUser, Clock);
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(_connection)
.AddInterceptors(interceptor)
.Options;
Db = new ApplicationDbContext(options);
Db.Database.EnsureCreated();
}
/// <summary>Adds a real user row so FK-bound rows (notifications, audit actor) satisfy the constraint.</summary>
public async Task<int> AddUserAsync(string userName)
{
var user = new User { UserName = userName };
Db.Set<User>().Add(user);
await Db.SaveChangesAsync();
return user.Id;
}
public void Dispose()
{
Db.Dispose();
_connection.Dispose();
}
}
internal sealed class TestClock : IDateTimeProvider
{
public DateTimeOffset UtcNow { get; set; }
}
internal sealed class TestCurrentUser : ICurrentUser
{
public int? UserId { get; set; }
public bool IsAuthenticated => UserId is not null;
public IReadOnlyList<string> Roles { get; set; } = [];
}
@@ -0,0 +1,52 @@
using Baya.Infrastructure.Persistence.Services.Configuration;
namespace Baya.Test.Foundation.Marketplace;
public sealed class PlatformConfigServiceTests
{
[Fact]
public async Task GetConfig_ParsesValueByDataType()
{
using var host = new OpsTestHost();
var config = new PlatformConfigService(host.Db, host.Cache);
Assert.Equal(0.10m, await config.GetConfig<decimal>("vat_rate"));
Assert.Equal(72, await config.GetConfig<int>("dispute_window_hours"));
Assert.Equal(30, await config.GetConfig<int>("booking_payment_deadline_minutes"));
Assert.Equal("platform", await config.GetConfig<string>("bnpl_merchant_of_record"));
}
[Fact]
public async Task SetConfig_UpdatesValue_WritesAuditRow_AndEvictsCache()
{
using var host = new OpsTestHost();
host.CurrentUser.UserId = await host.AddUserAsync("admin");
var config = new PlatformConfigService(host.Db, host.Cache);
// Prime the cache with the seeded value.
Assert.Equal(0.15m, await config.GetConfig<decimal>("platform_fee_rate"));
var updated = await config.SetConfig("platform_fee_rate", "0.18");
Assert.True(updated);
// Cache was evicted → the next read returns the new value.
Assert.Equal(0.18m, await config.GetConfig<decimal>("platform_fee_rate"));
var history = await config.GetConfigChangeHistory("platform_fee_rate", 1, 20);
Assert.Equal(1, history.Total);
var change = Assert.Single(history.Items);
Assert.Equal("updated", change.Action);
Assert.Equal(host.CurrentUser.UserId, change.ActorUserId);
Assert.Contains("0.15", change.ChangedFieldsJson);
Assert.Contains("0.18", change.ChangedFieldsJson);
}
[Fact]
public async Task SetConfig_MissingKey_ReturnsFalse()
{
using var host = new OpsTestHost();
var config = new PlatformConfigService(host.Db, host.Cache);
Assert.False(await config.SetConfig("does_not_exist", "x"));
}
}
@@ -0,0 +1,36 @@
using Baya.Domain.Entities.SupportAlerts;
using Baya.Infrastructure.Persistence.Services.SupportAlerts;
namespace Baya.Test.Foundation.Marketplace;
public sealed class SupportAlertServiceTests
{
[Fact]
public async Task Raise_List_Assign_Resolve_Lifecycle()
{
using var host = new OpsTestHost();
var admin = await host.AddUserAsync("admin");
var service = new SupportAlertService(host.Db, host.Clock);
var alertId = await service.RaiseAsync(
SupportAlertType.LowRating, "review", "42", SupportAlertSeverity.High, reviewId: 42);
var open = await service.ListAsync(null, SupportAlertStatus.Open, null, 1, 20);
var raised = Assert.Single(open.Items);
Assert.Equal(alertId, raised.Id);
Assert.Equal(42, raised.ReviewId);
Assert.Equal(SupportAlertStatus.Open, raised.Status);
Assert.True(await service.AssignAsync(alertId, admin));
var assigned = (await service.ListAsync(null, SupportAlertStatus.Assigned, null, 1, 20)).Items.Single();
Assert.Equal(admin, assigned.OwnerUserId);
Assert.True(await service.ResolveAsync(alertId, "Handled — nurse contacted."));
var resolved = (await service.ListAsync(null, SupportAlertStatus.Resolved, null, 1, 20)).Items.Single();
Assert.Equal("Handled — nurse contacted.", resolved.ResolutionNote);
Assert.NotNull(resolved.ResolvedAt);
// Forward-only: a resolved alert cannot be resolved again.
Assert.False(await service.ResolveAsync(alertId, "again"));
}
}