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:
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user