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,22 @@
namespace Baya.Domain.Common;
/// <summary>
/// Marks an entity whose row-level changes are written to the append-only <c>audit_logs</c> trail by the
/// SaveChanges audit interceptor. This is distinct from <see cref="IAuditableEntity"/> (which only stamps
/// the create/modify audit <em>fields</em>): implementing <c>IAuditable</c> additionally produces an
/// immutable audit-log row per insert/update/delete. Reserve it for compliance-sensitive entities —
/// <c>platform_configs</c> is auditable so finance can prove the exact rate in effect at any moment.
/// </summary>
public interface IAuditable : IEntity
{
}
/// <summary>
/// Applied to a property of an <see cref="IAuditable"/> entity whose value must never appear in the
/// audit diff (<c>changed_fields_json</c>). The interceptor writes a redaction marker instead of the
/// plaintext — used for encrypted/PII columns.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class AuditRedactedAttribute : Attribute
{
}
@@ -0,0 +1,21 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Analytics;
/// <summary>
/// High-volume behavioural/analytics event. NOT compliance evidence — it can be sampled, dropped, or
/// exported to a warehouse at scale. Append-only; the only timestamp it needs is <see cref="OccurredAt"/>.
/// </summary>
public class SystemEvent : IEntity
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? PropsJson { get; set; }
public int? UserId { get; set; }
public DateTimeOffset OccurredAt { get; set; }
}
@@ -0,0 +1,34 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Audit;
/// <summary>
/// Immutable, append-only record of a state change on a compliance-sensitive entity. Never updated or
/// deleted in app code — it is the system of record for disputes/finance. <see cref="EntityId"/> is a
/// string so the trail is polymorphic across differently-typed primary keys.
/// </summary>
public class AuditLog : IEntity
{
public long Id { get; set; }
public string EntityType { get; set; } = string.Empty;
public string EntityId { get; set; } = string.Empty;
public string Action { get; set; } = AuditAction.Updated;
public string? ChangedFieldsJson { get; set; }
public int? ActorUserId { get; set; }
public DateTimeOffset OccurredAt { get; set; }
}
/// <summary>Stable codes for <see cref="AuditLog.Action"/>.</summary>
public static class AuditAction
{
public const string Created = "created";
public const string Updated = "updated";
public const string Deleted = "deleted";
}
@@ -0,0 +1,31 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Configuration;
/// <summary>
/// A typed key-value runtime business parameter (commission rate, VAT, deadlines…). The app parses
/// <see cref="Value"/> according to <see cref="DataType"/>. Every change is audited (the type implements
/// <see cref="IAuditable"/>); there is no soft-delete — configs are updated in place and the audit trail
/// is their history.
/// </summary>
public class PlatformConfig : BaseEntity<long>, IAuditable
{
public string Key { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string DataType { get; set; } = ConfigDataType.String;
public string? Description { get; set; }
}
/// <summary>Stable codes for <see cref="PlatformConfig.DataType"/> — tells the app how to parse the raw value.</summary>
public static class ConfigDataType
{
public const string Decimal = "decimal";
public const string Int = "int";
public const string Bool = "bool";
public const string String = "string";
public const string Json = "json";
}
@@ -0,0 +1,28 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Holidays;
/// <summary>
/// A single day in the shared Iranian official/religious/national calendar. <see cref="IsBankClosed"/>
/// drives payout date shifting — when PAYA/SATNA banks are closed a weekly payout moves to the next
/// business day. The calendar is partly movable/lunar-Hijri, so the table is maintained rather than
/// computed.
/// </summary>
public class IranianHoliday : BaseEntity<long>
{
public DateOnly HolidayDate { get; set; }
public string NameFa { get; set; } = string.Empty;
public string Type { get; set; } = HolidayType.Official;
public bool IsBankClosed { get; set; }
}
/// <summary>Stable codes for <see cref="IranianHoliday.Type"/>.</summary>
public static class HolidayType
{
public const string Official = "official";
public const string Religious = "religious";
public const string National = "national";
}
@@ -0,0 +1,30 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Notifications;
/// <summary>
/// An in-app notification for a single user. <see cref="DataJson"/> is a typed, versioned deep-link
/// payload the front-end navigates on — not an arbitrary blob. Read notifications older than 90 days are
/// hard-deleted by the retention job; unread ones are never auto-deleted.
/// </summary>
public class Notification : IEntity
{
public long Id { get; set; }
public int UserId { get; set; }
public string Type { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string? Body { get; set; }
public string? DataJson { get; set; }
public bool IsRead { get; set; }
public DateTimeOffset? ReadAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
@@ -0,0 +1,71 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.SupportAlerts;
/// <summary>
/// An internal staff worklist item (low rating, EVV no-show, expired verification, payment anomaly…).
/// NEVER user-facing — it must not appear in any user-facing endpoint, query, or join. The subject is a
/// polymorphic <c>(EntityType, EntityId)</c> validated at the application layer (no DB FK); the common
/// booking/review cases also set the typed FK. Status is forward-only: open → assigned → resolved.
/// </summary>
public class SupportAlert : BaseEntity<long>
{
public string Type { get; set; } = string.Empty;
public string Severity { get; set; } = SupportAlertSeverity.Medium;
public string Status { get; set; } = SupportAlertStatus.Open;
public string EntityType { get; set; } = string.Empty;
public string EntityId { get; set; } = string.Empty;
// Typed FK columns for the common cases. The bookings/reviews tables arrive in later phases; the FK
// constraints are added there, so no relationship is configured now (the migration stays additive-safe).
public long? BookingId { get; set; }
public long? ReviewId { get; set; }
public int? OwnerUserId { get; set; }
public string? ResolutionNote { get; set; }
public DateTimeOffset? ResolvedAt { get; set; }
}
/// <summary>Stable codes for <see cref="SupportAlert.Type"/>.</summary>
public static class SupportAlertType
{
public const string LowRating = "low_rating";
public const string EvvNoShow = "evv_no_show";
public const string EvvLocationMismatch = "evv_location_mismatch";
public const string VerificationExpired = "verification_expired";
public const string PaymentAnomaly = "payment_anomaly";
public const string FraudSignal = "fraud_signal";
public static readonly IReadOnlyList<string> All =
[
LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, PaymentAnomaly, FraudSignal
];
}
/// <summary>Stable codes for <see cref="SupportAlert.Severity"/>.</summary>
public static class SupportAlertSeverity
{
public const string Low = "low";
public const string Medium = "medium";
public const string High = "high";
public static readonly IReadOnlyList<string> All = [Low, Medium, High];
}
/// <summary>Stable codes for <see cref="SupportAlert.Status"/> (forward-only).</summary>
public static class SupportAlertStatus
{
public const string Open = "open";
public const string Assigned = "assigned";
public const string Resolved = "resolved";
public static readonly IReadOnlyList<string> All = [Open, Assigned, Resolved];
}