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
@@ -1,22 +0,0 @@
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// No-op implementation of <see cref="INotificationDispatcher"/> — the mock seam. It logs that a
/// notification would be sent (no PII in the log). The real in-app write lands in backend-phase-15,
/// with SMS/push channels added behind the same interface.
/// </summary>
public sealed class LogNotificationDispatcher(ILogger<LogNotificationDispatcher> logger) : INotificationDispatcher
{
public ValueTask DispatchAsync(Notification notification, CancellationToken cancellationToken = default)
{
logger.LogInformation(
"Notification suppressed (mock dispatcher): channel {Channel} to user {UserId}",
notification.Channel,
notification.RecipientUserId);
return ValueTask.CompletedTask;
}
}
@@ -8,9 +8,10 @@ namespace Baya.Infrastructure.CrossCutting.ServiceConfiguration;
public static class ServiceCollectionExtension
{
/// <summary>
/// Registers the cross-cutting seams (time, PII encryption, cache, object storage, notifications)
/// with their in-memory/local mock implementations. Swapping in a real provider later is a
/// registration change here — callers depend only on the Application contracts.
/// Registers the cross-cutting seams (time, PII encryption, cache, object storage) with their
/// in-memory/local mock implementations. Swapping in a real provider later is a registration change
/// here — callers depend only on the Application contracts. (The real in-app
/// <c>INotificationDispatcher</c> needs the database, so it is registered in the Persistence layer.)
/// </summary>
public static IServiceCollection AddCrossCuttingSeams(this IServiceCollection services, IConfiguration configuration)
{
@@ -22,7 +23,6 @@ public static class ServiceCollectionExtension
services.AddSingleton<IFieldEncryptor, SymmetricFieldEncryptor>();
services.AddSingleton<ICacheService, MemoryCacheService>();
services.AddSingleton<IObjectStorage, LocalDiskObjectStorage>();
services.AddScoped<INotificationDispatcher, LogNotificationDispatcher>();
return services;
}
@@ -13,6 +13,10 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Baya.Test.Foundation" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\Baya.Application\Baya.Application.csproj" />
<ProjectReference Include="..\..\Core\Baya.Domain\Baya.Domain.csproj" />
@@ -0,0 +1,24 @@
using Baya.Domain.Entities.Analytics;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.AnalyticsConfig;
internal sealed class SystemEventConfig : IEntityTypeConfiguration<SystemEvent>
{
public void Configure(EntityTypeBuilder<SystemEvent> builder)
{
builder.ToTable("SystemEvents", "ops");
builder.Property(e => e.Name).HasMaxLength(100).IsRequired();
builder.HasIndex(e => e.Name);
builder.HasIndex(e => e.OccurredAt);
builder.HasOne<User>()
.WithMany()
.HasForeignKey(e => e.UserId)
.IsRequired(false);
}
}
@@ -0,0 +1,26 @@
using Baya.Domain.Entities.Audit;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.AuditConfig;
internal sealed class AuditLogConfig : IEntityTypeConfiguration<AuditLog>
{
public void Configure(EntityTypeBuilder<AuditLog> builder)
{
builder.ToTable("AuditLogs", "ops");
builder.Property(a => a.EntityType).HasMaxLength(100).IsRequired();
builder.Property(a => a.EntityId).HasMaxLength(100).IsRequired();
builder.Property(a => a.Action).HasMaxLength(20).IsRequired();
builder.HasIndex(a => new { a.EntityType, a.EntityId });
builder.HasIndex(a => a.OccurredAt);
builder.HasOne<User>()
.WithMany()
.HasForeignKey(a => a.ActorUserId)
.IsRequired(false);
}
}
@@ -0,0 +1,57 @@
using Baya.Domain.Entities.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.ConfigurationConfig;
internal sealed class PlatformConfigConfig : IEntityTypeConfiguration<PlatformConfig>
{
public void Configure(EntityTypeBuilder<PlatformConfig> builder)
{
builder.ToTable("PlatformConfigs", "ops");
builder.Property(c => c.Key).HasMaxLength(100).IsRequired();
builder.Property(c => c.Value).IsRequired();
builder.Property(c => c.DataType).HasMaxLength(20).IsRequired();
builder.Property(c => c.Description).HasMaxLength(500);
builder.HasIndex(c => c.Key).IsUnique();
builder.HasData(SeedData());
}
// Seeded via HasData so the values land with the baseline migration on a fresh DB. Defaults for keys
// the product docs don't pin down (fee/BNPL/cancellation) are decisions recorded in product doc 12.
private static object[] SeedData()
{
var ts = SeedConstants.Timestamp;
(long Id, string Key, string Value, string DataType, string Description)[] rows =
[
(1, "platform_fee_rate", "0.15", ConfigDataType.Decimal, "Balinyaar commission rate on the booking gross (fraction)."),
(2, "vat_rate", "0.10", ConfigDataType.Decimal, "VAT rate applied to the commission line only (fraction)."),
(3, "dispute_window_hours", "72", ConfigDataType.Int, "Hours after check-out a booking can be disputed."),
(4, "booking_payment_deadline_minutes", "30", ConfigDataType.Int, "Minutes a family has to pay before a pending booking expires."),
(5, "nurse_response_deadline_hours", "24", ConfigDataType.Int, "Hours a nurse has to accept/decline a booking request."),
(6, "nurse_payout_interval_days", "7", ConfigDataType.Int, "Weekly payout cadence in days."),
(7, "evv_location_tolerance_meters", "200", ConfigDataType.Int, "Allowed EVV check-in distance from the care address."),
(8, "min_rating_for_support_alert", "2", ConfigDataType.Decimal, "A review at or below this rating raises a support alert."),
(9, "bnpl_merchant_of_record", "platform", ConfigDataType.String, "Who is merchant of record for BNPL orders (platform|nurse)."),
(10, "bnpl_provider_commission_rate", "0.07", ConfigDataType.Decimal, "BNPL provider commission rate (fraction)."),
(11, "bnpl_settlement_timing", "immediate", ConfigDataType.String, "When BNPL settles funds to the platform (immediate|deferred)."),
(12, "cancellation_tiers", "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]", ConfigDataType.Json, "Tiered cancellation refund policy: refund_percent by hours before the visit."),
];
return rows
.Select(r => (object)new
{
r.Id,
r.Key,
r.Value,
r.DataType,
r.Description,
CreatedAt = ts
})
.ToArray();
}
}
@@ -0,0 +1,50 @@
using Baya.Domain.Entities.Holidays;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.HolidaysConfig;
internal sealed class IranianHolidayConfig : IEntityTypeConfiguration<IranianHoliday>
{
public void Configure(EntityTypeBuilder<IranianHoliday> builder)
{
builder.ToTable("IranianHolidays", "ops");
builder.Property(h => h.NameFa).HasMaxLength(200).IsRequired();
builder.Property(h => h.Type).HasMaxLength(20).IsRequired();
builder.HasIndex(h => h.HolidayDate).IsUnique();
builder.HasData(SeedData());
}
// A representative sample so IsBankClosed/NextBusinessDay are testable. The full, maintained,
// partly-lunar-Hijri feed is deferred behind IHolidayCalendar's "make it real" path.
private static object[] SeedData()
{
var ts = SeedConstants.Timestamp;
(long Id, DateOnly Date, string NameFa, string Type, bool BankClosed)[] rows =
[
(1, new DateOnly(2026, 2, 11), "پیروزی انقلاب اسلامی", HolidayType.National, true),
(2, new DateOnly(2026, 3, 21), "نوروز", HolidayType.National, true),
(3, new DateOnly(2026, 3, 22), "نوروز", HolidayType.National, true),
(4, new DateOnly(2026, 3, 23), "نوروز", HolidayType.National, true),
(5, new DateOnly(2026, 3, 24), "نوروز", HolidayType.National, true),
(6, new DateOnly(2026, 4, 1), "روز طبیعت (سیزده‌به‌در)", HolidayType.Official, true),
(7, new DateOnly(2026, 6, 26), "عید سعید قربان", HolidayType.Religious, true),
];
return rows
.Select(r => (object)new
{
r.Id,
HolidayDate = r.Date,
r.NameFa,
r.Type,
IsBankClosed = r.BankClosed,
CreatedAt = ts
})
.ToArray();
}
}
@@ -0,0 +1,26 @@
using Baya.Domain.Entities.Notifications;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.NotificationsConfig;
internal sealed class NotificationConfig : IEntityTypeConfiguration<Notification>
{
public void Configure(EntityTypeBuilder<Notification> builder)
{
builder.ToTable("Notifications", "ops");
builder.Property(n => n.Type).HasMaxLength(100).IsRequired();
builder.Property(n => n.Title).HasMaxLength(200).IsRequired();
builder.Property(n => n.IsRead).HasDefaultValue(false);
// Serves unread-first paging and the cheap unread-count query.
builder.HasIndex(n => new { n.UserId, n.IsRead, n.CreatedAt });
builder.HasOne<User>()
.WithMany()
.HasForeignKey(n => n.UserId)
.IsRequired();
}
}
@@ -0,0 +1,10 @@
namespace Baya.Infrastructure.Persistence.Configuration;
/// <summary>
/// Fixed values used by <c>HasData</c> seeding so the generated migration is deterministic. A literal
/// timestamp (never <c>DateTime.Now</c>) keeps the model snapshot stable across migration regenerations.
/// </summary>
internal static class SeedConstants
{
public static readonly DateTimeOffset Timestamp = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
}
@@ -0,0 +1,31 @@
using Baya.Domain.Entities.SupportAlerts;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.SupportAlertsConfig;
internal sealed class SupportAlertConfig : IEntityTypeConfiguration<SupportAlert>
{
public void Configure(EntityTypeBuilder<SupportAlert> builder)
{
builder.ToTable("SupportAlerts", "ops");
builder.Property(a => a.Type).HasMaxLength(40).IsRequired();
builder.Property(a => a.Severity).HasMaxLength(20).IsRequired();
builder.Property(a => a.Status).HasMaxLength(20).IsRequired();
builder.Property(a => a.EntityType).HasMaxLength(100).IsRequired();
builder.Property(a => a.EntityId).HasMaxLength(100).IsRequired();
builder.HasIndex(a => a.Status);
builder.HasIndex(a => a.Type);
builder.HasOne<User>()
.WithMany()
.HasForeignKey(a => a.OwnerUserId)
.IsRequired(false);
// BookingId/ReviewId are declared columns only — the FK constraints are added by the phases that
// create the bookings/reviews tables (b9/b14), keeping this baseline migration additive-safe.
}
}
@@ -1,25 +1,36 @@
#nullable enable
using System.Reflection;
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Baya.Domain.Common;
using Baya.Domain.Entities.Audit;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Baya.Infrastructure.Persistence.Interceptors;
/// <summary>
/// Stamps audit fields on every save: <c>CreatedAt</c>/<c>CreatedById</c> on insert and
/// <c>ModifiedAt</c>/<c>ModifiedById</c> on update, sourcing time from <see cref="IDateTimeProvider"/>
/// and the acting user from <see cref="ICurrentUser"/>. Handlers never set these fields.
/// This is the extension point backend-phase-1 builds on to also write append-only audit-log rows.
/// On every save this interceptor does two things in the caller's transaction:
/// (1) stamps <c>CreatedAt</c>/<c>CreatedById</c> on insert and <c>ModifiedAt</c>/<c>ModifiedById</c> on
/// update (from <see cref="IDateTimeProvider"/> + <see cref="ICurrentUser"/>); and
/// (2) appends an immutable <c>audit_logs</c> row for every change to an <see cref="IAuditable"/> entity,
/// with a redacted old/new diff. The audit rows ride the same <c>SaveChanges</c>, so a config change and
/// its audit entry commit atomically. Handlers never set audit fields or write audit rows themselves.
/// </summary>
public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimeProvider dateTimeProvider)
: SaveChangesInterceptor
{
private const string RedactedMarker = "<redacted>";
private static readonly HashSet<string> NonBusinessFields =
["Id", "CreatedAt", "ModifiedAt", "CreatedById", "ModifiedById"];
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
Stamp(eventData.Context);
Process(eventData.Context);
return base.SavingChanges(eventData, result);
}
@@ -28,11 +39,11 @@ public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimePro
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
Stamp(eventData.Context);
Process(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private void Stamp(DbContext? context)
private void Process(DbContext? context)
{
if (context is null)
return;
@@ -40,13 +51,28 @@ public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimePro
var now = dateTimeProvider.UtcNow;
var userId = currentUser.UserId;
foreach (var entry in context.ChangeTracker.Entries<ITimeModification>())
// Snapshot the entries before we add any audit rows, so adding to the context can't disturb the loop.
var entries = context.ChangeTracker.Entries().ToList();
Stamp(entries, now, userId);
var auditLogs = CollectAuditLogs(entries, now, userId);
if (auditLogs.Count > 0)
context.Set<AuditLog>().AddRange(auditLogs);
}
private static void Stamp(IReadOnlyList<EntityEntry> entries, DateTimeOffset now, int? userId)
{
foreach (var entry in entries)
{
if (entry.Entity is not ITimeModification timed)
continue;
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.ModifiedAt = now;
timed.CreatedAt = now;
timed.ModifiedAt = now;
if (entry.Entity is IAuditableEntity addedAuditable)
{
addedAuditable.CreatedById = userId;
@@ -56,7 +82,7 @@ public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimePro
break;
case EntityState.Modified:
entry.Entity.ModifiedAt = now;
timed.ModifiedAt = now;
if (entry.Entity is IAuditableEntity modifiedAuditable)
modifiedAuditable.ModifiedById = userId;
@@ -64,4 +90,80 @@ public sealed class AuditFieldInterceptor(ICurrentUser currentUser, IDateTimePro
}
}
}
private List<AuditLog> CollectAuditLogs(IReadOnlyList<EntityEntry> entries, DateTimeOffset now, int? userId)
{
var logs = new List<AuditLog>();
foreach (var entry in entries)
{
if (entry.Entity is not IAuditable)
continue;
var (action, useOriginal) = entry.State switch
{
EntityState.Added => (AuditAction.Created, false),
EntityState.Modified => (AuditAction.Updated, false),
EntityState.Deleted => (AuditAction.Deleted, true),
_ => (string.Empty, false)
};
if (action.Length == 0)
continue;
logs.Add(new AuditLog
{
EntityType = entry.Metadata.ClrType.Name,
EntityId = ResolveEntityId(entry, useOriginal),
Action = action,
ChangedFieldsJson = BuildDiff(entry),
ActorUserId = userId,
OccurredAt = now
});
}
return logs;
}
private static string ResolveEntityId(EntityEntry entry, bool useOriginal)
{
var keyProperty = entry.Metadata.FindPrimaryKey()?.Properties.FirstOrDefault();
if (keyProperty is null)
return string.Empty;
var property = entry.Property(keyProperty.Name);
var value = useOriginal ? property.OriginalValue : property.CurrentValue;
return value?.ToString() ?? string.Empty;
}
// { "Field": { "old": <old>, "new": <new> } } for the changed business fields; PII columns marked
// [AuditRedacted] are written as a redaction marker, never plaintext.
private static string? BuildDiff(EntityEntry entry)
{
var diff = new Dictionary<string, object?>();
foreach (var property in entry.Properties)
{
var name = property.Metadata.Name;
if (NonBusinessFields.Contains(name))
continue;
var isDeletedOrAdded = entry.State is EntityState.Added or EntityState.Deleted;
if (entry.State == EntityState.Modified && !property.IsModified)
continue;
var redacted = property.Metadata.PropertyInfo?.GetCustomAttribute<AuditRedactedAttribute>() is not null;
object? oldValue = entry.State == EntityState.Added ? null : Sanitize(property.OriginalValue, redacted);
object? newValue = entry.State == EntityState.Deleted ? null : Sanitize(property.CurrentValue, redacted);
if (isDeletedOrAdded || !Equals(property.OriginalValue, property.CurrentValue))
diff[name] = new { old = oldValue, @new = newValue };
}
return diff.Count == 0 ? null : JsonSerializer.Serialize(diff);
}
private static object? Sanitize(object? value, bool redacted) =>
value is null ? null : redacted ? RedactedMarker : value;
}
@@ -0,0 +1,871 @@
// <auto-generated />
using System;
using Baya.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260701193257_InitialMarketplaceBaseline")]
partial class InitialMarketplaceBaseline
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("datetimeoffset");
b.Property<string>("PropsJson")
.HasColumnType("nvarchar(max)");
b.Property<int?>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Name");
b.HasIndex("OccurredAt");
b.HasIndex("UserId");
b.ToTable("SystemEvents", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int?>("ActorUserId")
.HasColumnType("int");
b.Property<string>("ChangedFieldsJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("EntityId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("ActorUserId");
b.HasIndex("OccurredAt");
b.HasIndex("EntityType", "EntityId");
b.ToTable("AuditLogs", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("DataType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("PlatformConfigs", "ops");
b.HasData(
new
{
Id = 1L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "decimal",
Description = "Balinyaar commission rate on the booking gross (fraction).",
Key = "platform_fee_rate",
Value = "0.15"
},
new
{
Id = 2L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "decimal",
Description = "VAT rate applied to the commission line only (fraction).",
Key = "vat_rate",
Value = "0.10"
},
new
{
Id = 3L,
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 after check-out a booking can be disputed.",
Key = "dispute_window_hours",
Value = "72"
},
new
{
Id = 4L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "int",
Description = "Minutes a family has to pay before a pending booking expires.",
Key = "booking_payment_deadline_minutes",
Value = "30"
},
new
{
Id = 5L,
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 a nurse has to accept/decline a booking request.",
Key = "nurse_response_deadline_hours",
Value = "24"
},
new
{
Id = 6L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "int",
Description = "Weekly payout cadence in days.",
Key = "nurse_payout_interval_days",
Value = "7"
},
new
{
Id = 7L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "int",
Description = "Allowed EVV check-in distance from the care address.",
Key = "evv_location_tolerance_meters",
Value = "200"
},
new
{
Id = 8L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "decimal",
Description = "A review at or below this rating raises a support alert.",
Key = "min_rating_for_support_alert",
Value = "2"
},
new
{
Id = 9L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "string",
Description = "Who is merchant of record for BNPL orders (platform|nurse).",
Key = "bnpl_merchant_of_record",
Value = "platform"
},
new
{
Id = 10L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "decimal",
Description = "BNPL provider commission rate (fraction).",
Key = "bnpl_provider_commission_rate",
Value = "0.07"
},
new
{
Id = 11L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "string",
Description = "When BNPL settles funds to the platform (immediate|deferred).",
Key = "bnpl_settlement_timing",
Value = "immediate"
},
new
{
Id = 12L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "json",
Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.",
Key = "cancellation_tiers",
Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]"
});
});
modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateOnly>("HolidayDate")
.HasColumnType("date");
b.Property<bool>("IsBankClosed")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("NameFa")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.HasKey("Id");
b.HasIndex("HolidayDate")
.IsUnique();
b.ToTable("IranianHolidays", "ops");
b.HasData(
new
{
Id = 1L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
HolidayDate = new DateOnly(2026, 2, 11),
IsBankClosed = true,
NameFa = "پیروزی انقلاب اسلامی",
Type = "national"
},
new
{
Id = 2L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
HolidayDate = new DateOnly(2026, 3, 21),
IsBankClosed = true,
NameFa = "نوروز",
Type = "national"
},
new
{
Id = 3L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
HolidayDate = new DateOnly(2026, 3, 22),
IsBankClosed = true,
NameFa = "نوروز",
Type = "national"
},
new
{
Id = 4L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
HolidayDate = new DateOnly(2026, 3, 23),
IsBankClosed = true,
NameFa = "نوروز",
Type = "national"
},
new
{
Id = 5L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
HolidayDate = new DateOnly(2026, 3, 24),
IsBankClosed = true,
NameFa = "نوروز",
Type = "national"
},
new
{
Id = 6L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
HolidayDate = new DateOnly(2026, 4, 1),
IsBankClosed = true,
NameFa = "روز طبیعت (سیزده‌به‌در)",
Type = "official"
},
new
{
Id = 7L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
HolidayDate = new DateOnly(2026, 6, 26),
IsBankClosed = true,
NameFa = "عید سعید قربان",
Type = "religious"
});
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Body")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("DataJson")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsRead")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("ReadAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId", "IsRead", "CreatedAt");
b.ToTable("Notifications", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long?>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("EntityId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int?>("OwnerUserId")
.HasColumnType("int");
b.Property<string>("ResolutionNote")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("datetimeoffset");
b.Property<long?>("ReviewId")
.HasColumnType("bigint");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.HasIndex("Status");
b.HasIndex("Type");
b.ToTable("SupportAlerts", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.User.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("Roles", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedClaim")
.HasColumnType("datetime2");
b.Property<int>("RoleId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("RoleClaims", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.User.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("UserId");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<string>("FamilyName")
.HasColumnType("nvarchar(max)");
b.Property<string>("GeneratedCode")
.HasColumnType("nvarchar(max)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("Users", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserClaims", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<DateTime>("LoggedOn")
.HasColumnType("datetime2");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("UserLogins", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<bool>("IsValid")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserRefreshTokens", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("int");
b.Property<int>("RoleId")
.HasColumnType("int");
b.Property<DateTime>("CreatedUserRoleDate")
.HasColumnType("datetime2");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b =>
{
b.Property<int>("UserId")
.HasColumnType("int");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<DateTime>("GeneratedTime")
.HasColumnType("datetime2");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("UserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("ActorUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("OwnerUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b =>
{
b.HasOne("Baya.Domain.Entities.User.Role", "Role")
.WithMany("Claims")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Role");
});
modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", "User")
.WithMany("Claims")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", "User")
.WithMany("Logins")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", "User")
.WithMany("UserRefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b =>
{
b.HasOne("Baya.Domain.Entities.User.Role", "Role")
.WithMany("Users")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.User.User", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", "User")
.WithMany("Tokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Baya.Domain.Entities.User.Role", b =>
{
b.Navigation("Claims");
b.Navigation("Users");
});
modelBuilder.Entity("Baya.Domain.Entities.User.User", b =>
{
b.Navigation("Claims");
b.Navigation("Logins");
b.Navigation("Tokens");
b.Navigation("UserRefreshTokens");
b.Navigation("UserRoles");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,308 @@
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 InitialMarketplaceBaseline : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "ops");
migrationBuilder.CreateTable(
name: "AuditLogs",
schema: "ops",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
EntityType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
EntityId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Action = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
ChangedFieldsJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
ActorUserId = table.Column<int>(type: "int", nullable: true),
OccurredAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AuditLogs", x => x.Id);
table.ForeignKey(
name: "FK_AuditLogs_Users_ActorUserId",
column: x => x.ActorUserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
});
migrationBuilder.CreateTable(
name: "IranianHolidays",
schema: "ops",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
HolidayDate = table.Column<DateOnly>(type: "date", nullable: false),
NameFa = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Type = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
IsBankClosed = table.Column<bool>(type: "bit", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_IranianHolidays", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Notifications",
schema: "ops",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<int>(type: "int", nullable: false),
Type = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Title = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Body = table.Column<string>(type: "nvarchar(max)", nullable: true),
DataJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsRead = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
ReadAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Notifications", x => x.Id);
table.ForeignKey(
name: "FK_Notifications_Users_UserId",
column: x => x.UserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "PlatformConfigs",
schema: "ops",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Key = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
DataType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PlatformConfigs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SupportAlerts",
schema: "ops",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Type = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
Severity = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
EntityType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
EntityId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: true),
ReviewId = table.Column<long>(type: "bigint", nullable: true),
OwnerUserId = table.Column<int>(type: "int", nullable: true),
ResolutionNote = table.Column<string>(type: "nvarchar(max)", nullable: true),
ResolvedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SupportAlerts", x => x.Id);
table.ForeignKey(
name: "FK_SupportAlerts_Users_OwnerUserId",
column: x => x.OwnerUserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
});
migrationBuilder.CreateTable(
name: "SystemEvents",
schema: "ops",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
PropsJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
UserId = table.Column<int>(type: "int", nullable: true),
OccurredAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SystemEvents", x => x.Id);
table.ForeignKey(
name: "FK_SystemEvents_Users_UserId",
column: x => x.UserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
});
migrationBuilder.InsertData(
schema: "ops",
table: "IranianHolidays",
columns: new[] { "Id", "CreatedAt", "CreatedById", "HolidayDate", "IsBankClosed", "ModifiedAt", "ModifiedById", "NameFa", "Type" },
values: new object[,]
{
{ 1L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, new DateOnly(2026, 2, 11), true, null, null, "پیروزی انقلاب اسلامی", "national" },
{ 2L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, new DateOnly(2026, 3, 21), true, null, null, "نوروز", "national" },
{ 3L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, new DateOnly(2026, 3, 22), true, null, null, "نوروز", "national" },
{ 4L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, new DateOnly(2026, 3, 23), true, null, null, "نوروز", "national" },
{ 5L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, new DateOnly(2026, 3, 24), true, null, null, "نوروز", "national" },
{ 6L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, new DateOnly(2026, 4, 1), true, null, null, "روز طبیعت (سیزده‌به‌در)", "official" },
{ 7L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, new DateOnly(2026, 6, 26), true, null, null, "عید سعید قربان", "religious" }
});
migrationBuilder.InsertData(
schema: "ops",
table: "PlatformConfigs",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DataType", "Description", "Key", "ModifiedAt", "ModifiedById", "Value" },
values: new object[,]
{
{ 1L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "decimal", "Balinyaar commission rate on the booking gross (fraction).", "platform_fee_rate", null, null, "0.15" },
{ 2L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "decimal", "VAT rate applied to the commission line only (fraction).", "vat_rate", null, null, "0.10" },
{ 3L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Hours after check-out a booking can be disputed.", "dispute_window_hours", null, null, "72" },
{ 4L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Minutes a family has to pay before a pending booking expires.", "booking_payment_deadline_minutes", null, null, "30" },
{ 5L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Hours a nurse has to accept/decline a booking request.", "nurse_response_deadline_hours", null, null, "24" },
{ 6L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Weekly payout cadence in days.", "nurse_payout_interval_days", null, null, "7" },
{ 7L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "int", "Allowed EVV check-in distance from the care address.", "evv_location_tolerance_meters", null, null, "200" },
{ 8L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "decimal", "A review at or below this rating raises a support alert.", "min_rating_for_support_alert", null, null, "2" },
{ 9L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "string", "Who is merchant of record for BNPL orders (platform|nurse).", "bnpl_merchant_of_record", null, null, "platform" },
{ 10L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "decimal", "BNPL provider commission rate (fraction).", "bnpl_provider_commission_rate", null, null, "0.07" },
{ 11L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "string", "When BNPL settles funds to the platform (immediate|deferred).", "bnpl_settlement_timing", null, null, "immediate" },
{ 12L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, "json", "Tiered cancellation refund policy: refund_percent by hours before the visit.", "cancellation_tiers", null, null, "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" }
});
migrationBuilder.CreateIndex(
name: "IX_AuditLogs_ActorUserId",
schema: "ops",
table: "AuditLogs",
column: "ActorUserId");
migrationBuilder.CreateIndex(
name: "IX_AuditLogs_EntityType_EntityId",
schema: "ops",
table: "AuditLogs",
columns: new[] { "EntityType", "EntityId" });
migrationBuilder.CreateIndex(
name: "IX_AuditLogs_OccurredAt",
schema: "ops",
table: "AuditLogs",
column: "OccurredAt");
migrationBuilder.CreateIndex(
name: "IX_IranianHolidays_HolidayDate",
schema: "ops",
table: "IranianHolidays",
column: "HolidayDate",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_IsRead_CreatedAt",
schema: "ops",
table: "Notifications",
columns: new[] { "UserId", "IsRead", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_PlatformConfigs_Key",
schema: "ops",
table: "PlatformConfigs",
column: "Key",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SupportAlerts_OwnerUserId",
schema: "ops",
table: "SupportAlerts",
column: "OwnerUserId");
migrationBuilder.CreateIndex(
name: "IX_SupportAlerts_Status",
schema: "ops",
table: "SupportAlerts",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_SupportAlerts_Type",
schema: "ops",
table: "SupportAlerts",
column: "Type");
migrationBuilder.CreateIndex(
name: "IX_SystemEvents_Name",
schema: "ops",
table: "SystemEvents",
column: "Name");
migrationBuilder.CreateIndex(
name: "IX_SystemEvents_OccurredAt",
schema: "ops",
table: "SystemEvents",
column: "OccurredAt");
migrationBuilder.CreateIndex(
name: "IX_SystemEvents_UserId",
schema: "ops",
table: "SystemEvents",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AuditLogs",
schema: "ops");
migrationBuilder.DropTable(
name: "IranianHolidays",
schema: "ops");
migrationBuilder.DropTable(
name: "Notifications",
schema: "ops");
migrationBuilder.DropTable(
name: "PlatformConfigs",
schema: "ops");
migrationBuilder.DropTable(
name: "SupportAlerts",
schema: "ops");
migrationBuilder.DropTable(
name: "SystemEvents",
schema: "ops");
}
}
}
@@ -22,6 +22,463 @@ namespace Baya.Infrastructure.Persistence.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("datetimeoffset");
b.Property<string>("PropsJson")
.HasColumnType("nvarchar(max)");
b.Property<int?>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Name");
b.HasIndex("OccurredAt");
b.HasIndex("UserId");
b.ToTable("SystemEvents", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int?>("ActorUserId")
.HasColumnType("int");
b.Property<string>("ChangedFieldsJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("EntityId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("ActorUserId");
b.HasIndex("OccurredAt");
b.HasIndex("EntityType", "EntityId");
b.ToTable("AuditLogs", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("DataType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("PlatformConfigs", "ops");
b.HasData(
new
{
Id = 1L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "decimal",
Description = "Balinyaar commission rate on the booking gross (fraction).",
Key = "platform_fee_rate",
Value = "0.15"
},
new
{
Id = 2L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "decimal",
Description = "VAT rate applied to the commission line only (fraction).",
Key = "vat_rate",
Value = "0.10"
},
new
{
Id = 3L,
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 after check-out a booking can be disputed.",
Key = "dispute_window_hours",
Value = "72"
},
new
{
Id = 4L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "int",
Description = "Minutes a family has to pay before a pending booking expires.",
Key = "booking_payment_deadline_minutes",
Value = "30"
},
new
{
Id = 5L,
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 a nurse has to accept/decline a booking request.",
Key = "nurse_response_deadline_hours",
Value = "24"
},
new
{
Id = 6L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "int",
Description = "Weekly payout cadence in days.",
Key = "nurse_payout_interval_days",
Value = "7"
},
new
{
Id = 7L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "int",
Description = "Allowed EVV check-in distance from the care address.",
Key = "evv_location_tolerance_meters",
Value = "200"
},
new
{
Id = 8L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "decimal",
Description = "A review at or below this rating raises a support alert.",
Key = "min_rating_for_support_alert",
Value = "2"
},
new
{
Id = 9L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "string",
Description = "Who is merchant of record for BNPL orders (platform|nurse).",
Key = "bnpl_merchant_of_record",
Value = "platform"
},
new
{
Id = 10L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "decimal",
Description = "BNPL provider commission rate (fraction).",
Key = "bnpl_provider_commission_rate",
Value = "0.07"
},
new
{
Id = 11L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "string",
Description = "When BNPL settles funds to the platform (immediate|deferred).",
Key = "bnpl_settlement_timing",
Value = "immediate"
},
new
{
Id = 12L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
DataType = "json",
Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.",
Key = "cancellation_tiers",
Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]"
});
});
modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateOnly>("HolidayDate")
.HasColumnType("date");
b.Property<bool>("IsBankClosed")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("NameFa")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.HasKey("Id");
b.HasIndex("HolidayDate")
.IsUnique();
b.ToTable("IranianHolidays", "ops");
b.HasData(
new
{
Id = 1L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
HolidayDate = new DateOnly(2026, 2, 11),
IsBankClosed = true,
NameFa = "پیروزی انقلاب اسلامی",
Type = "national"
},
new
{
Id = 2L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
HolidayDate = new DateOnly(2026, 3, 21),
IsBankClosed = true,
NameFa = "نوروز",
Type = "national"
},
new
{
Id = 3L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
HolidayDate = new DateOnly(2026, 3, 22),
IsBankClosed = true,
NameFa = "نوروز",
Type = "national"
},
new
{
Id = 4L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
HolidayDate = new DateOnly(2026, 3, 23),
IsBankClosed = true,
NameFa = "نوروز",
Type = "national"
},
new
{
Id = 5L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
HolidayDate = new DateOnly(2026, 3, 24),
IsBankClosed = true,
NameFa = "نوروز",
Type = "national"
},
new
{
Id = 6L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
HolidayDate = new DateOnly(2026, 4, 1),
IsBankClosed = true,
NameFa = "روز طبیعت (سیزده‌به‌در)",
Type = "official"
},
new
{
Id = 7L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
HolidayDate = new DateOnly(2026, 6, 26),
IsBankClosed = true,
NameFa = "عید سعید قربان",
Type = "religious"
});
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Body")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("DataJson")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsRead")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("ReadAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId", "IsRead", "CreatedAt");
b.ToTable("Notifications", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long?>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("EntityId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int?>("OwnerUserId")
.HasColumnType("int");
b.Property<string>("ResolutionNote")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("datetimeoffset");
b.Property<long?>("ReviewId")
.HasColumnType("bigint");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.HasIndex("Status");
b.HasIndex("Type");
b.ToTable("SupportAlerts", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.User.Role", b =>
{
b.Property<int>("Id")
@@ -282,6 +739,36 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("UserTokens", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("UserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("ActorUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("OwnerUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b =>
{
b.HasOne("Baya.Domain.Entities.User.Role", "Role")
@@ -1,6 +1,19 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Analytics;
using Baya.Application.Contracts.Audit;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Holidays;
using Baya.Application.Contracts.Notifications;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Infrastructure.Persistence.Interceptors;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Infrastructure.Persistence.Services.Analytics;
using Baya.Infrastructure.Persistence.Services.Audit;
using Baya.Infrastructure.Persistence.Services.Configuration;
using Baya.Infrastructure.Persistence.Services.Holidays;
using Baya.Infrastructure.Persistence.Services.Notifications;
using Baya.Infrastructure.Persistence.Services.SupportAlerts;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -23,6 +36,21 @@ public static class ServiceCollectionExtensions
.AddInterceptors(serviceProvider.GetRequiredService<AuditFieldInterceptor>());
});
// Platform-signal facades — DB-backed implementations of the Application contracts other domains
// (b2…b15) depend on. Config/holiday lookups cache through ICacheService.
services.AddScoped<IPlatformConfig, PlatformConfigService>();
services.AddScoped<IHolidayCalendar, HolidayCalendarService>();
services.AddScoped<IAnalyticsSink, AnalyticsSink>();
services.AddScoped<IAuditLogger, AuditLogger>();
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<ISupportAlertService, SupportAlertService>();
// Supersedes the b0 log/no-op stub with the real in-app notifications write.
services.AddScoped<INotificationDispatcher, InAppNotificationDispatcher>();
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
services.AddHostedService<NotificationRetentionHostedService>();
return services;
}
@@ -0,0 +1,40 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Analytics;
using Baya.Application.Contracts.Common;
using Baya.Domain.Entities.Analytics;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Analytics;
/// <summary>
/// Fire-and-forget analytics sink — the mock inserts a <c>system_events</c> row. A failure is logged and
/// swallowed so it can never surface to, or slow, the caller's operation. Compliance facts never come
/// here (they go to the audit trail).
/// </summary>
internal sealed class AnalyticsSink(
ApplicationDbContext db,
ICurrentUser currentUser,
IDateTimeProvider dateTimeProvider,
ILogger<AnalyticsSink> logger) : IAnalyticsSink
{
public async ValueTask EmitAsync(string name, object props, CancellationToken cancellationToken = default)
{
try
{
db.Set<SystemEvent>().Add(new SystemEvent
{
Name = name,
PropsJson = JsonSerializer.Serialize(props),
UserId = currentUser.UserId,
OccurredAt = dateTimeProvider.UtcNow
});
await db.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Analytics emit failed for event {EventName}", name);
}
}
}
@@ -0,0 +1,64 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Audit;
using Baya.Application.Contracts.Common;
using Baya.Application.Models.Audit;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Audit;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Services.Audit;
/// <summary>
/// Explicit append-only audit writer + trail reader. The trail is immutable: there is deliberately no
/// update or delete path here. Row-level diffs on auditable entities are captured automatically by the
/// SaveChanges interceptor; this contract covers state changes that have no tracked-entity diff.
/// </summary>
internal sealed class AuditLogger(
ApplicationDbContext db,
ICurrentUser currentUser,
IDateTimeProvider dateTimeProvider) : IAuditLogger
{
public async ValueTask WriteAsync(
string entityType,
string entityId,
string action,
IReadOnlyDictionary<string, object?>? changedFields = null,
CancellationToken cancellationToken = default)
{
db.Set<AuditLog>().Add(new AuditLog
{
EntityType = entityType,
EntityId = entityId,
Action = action,
ChangedFieldsJson = changedFields is null ? null : JsonSerializer.Serialize(changedFields),
ActorUserId = currentUser.UserId,
OccurredAt = dateTimeProvider.UtcNow
});
await db.SaveChangesAsync(cancellationToken);
}
public async ValueTask<PagedResult<AuditLogDto>> GetTrailAsync(
string entityType,
string entityId,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = db.Set<AuditLog>()
.AsNoTracking()
.Where(a => a.EntityType == entityType && a.EntityId == entityId)
// Id is a monotonic identity → newest-first and deterministic (no timestamp ties).
.OrderByDescending(a => a.Id);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(a => new AuditLogDto(a.Id, a.EntityType, a.EntityId, a.Action, a.ChangedFieldsJson, a.ActorUserId, a.OccurredAt))
.ToListAsync(cancellationToken);
return new PagedResult<AuditLogDto>(items, total, page, pageSize);
}
}
@@ -0,0 +1,113 @@
#nullable enable
using System.Globalization;
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Models.Common;
using Baya.Application.Models.Configuration;
using Baya.Domain.Entities.Audit;
using Baya.Domain.Entities.Configuration;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Services.Configuration;
/// <summary>
/// Cached, typed accessor over <c>platform_configs</c>. Reads go through <see cref="ICacheService"/>;
/// a write updates the row (audited by the SaveChanges interceptor in the same transaction) and evicts
/// the cache key so the next read sees the new value.
/// </summary>
internal sealed class PlatformConfigService(ApplicationDbContext db, ICacheService cache) : IPlatformConfig
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(30);
private static string CacheKey(string key) => $"platform_config:{key}";
public async ValueTask<T> GetConfig<T>(string key, CancellationToken cancellationToken = default)
{
var dto = await cache.GetOrCreateAsync(
CacheKey(key),
async ct => await db.Set<PlatformConfig>()
.AsNoTracking()
.Where(c => c.Key == key)
.Select(c => new PlatformConfigDto(c.Key, c.Value, c.DataType, c.Description))
.FirstOrDefaultAsync(ct),
CacheTtl,
cancellationToken);
if (dto is null)
throw new InvalidOperationException($"Platform config key '{key}' does not exist.");
return Parse<T>(dto.Value, dto.DataType);
}
public async ValueTask<bool> SetConfig(string key, string value, CancellationToken cancellationToken = default)
{
var entity = await db.Set<PlatformConfig>().FirstOrDefaultAsync(c => c.Key == key, cancellationToken);
if (entity is null)
return false;
entity.Value = value;
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveAsync(CacheKey(key), cancellationToken);
return true;
}
public async ValueTask<PagedResult<PlatformConfigDto>> ListAsync(int page, int pageSize, CancellationToken cancellationToken = default)
{
var query = db.Set<PlatformConfig>().AsNoTracking().OrderBy(c => c.Key);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(c => new PlatformConfigDto(c.Key, c.Value, c.DataType, c.Description))
.ToListAsync(cancellationToken);
return new PagedResult<PlatformConfigDto>(items, total, page, pageSize);
}
public async ValueTask<PagedResult<ConfigChangeDto>> GetConfigChangeHistory(string key, int page, int pageSize, CancellationToken cancellationToken = default)
{
var configId = await db.Set<PlatformConfig>()
.AsNoTracking()
.Where(c => c.Key == key)
.Select(c => (long?)c.Id)
.FirstOrDefaultAsync(cancellationToken);
if (configId is null)
return new PagedResult<ConfigChangeDto>([], 0, page, pageSize);
var entityId = configId.Value.ToString(CultureInfo.InvariantCulture);
var query = db.Set<AuditLog>()
.AsNoTracking()
.Where(a => a.EntityType == nameof(PlatformConfig) && a.EntityId == entityId)
// Id is a monotonic identity → newest-first and deterministic (no timestamp ties).
.OrderByDescending(a => a.Id);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(a => new ConfigChangeDto(a.Id, a.Action, a.ChangedFieldsJson, a.ActorUserId, a.OccurredAt))
.ToListAsync(cancellationToken);
return new PagedResult<ConfigChangeDto>(items, total, page, pageSize);
}
private static T Parse<T>(string value, string dataType)
{
object parsed = dataType switch
{
ConfigDataType.Decimal => decimal.Parse(value, CultureInfo.InvariantCulture),
ConfigDataType.Int => int.Parse(value, CultureInfo.InvariantCulture),
ConfigDataType.Bool => bool.Parse(value),
ConfigDataType.Json => JsonSerializer.Deserialize<T>(value)
?? throw new InvalidOperationException($"Config value for type '{typeof(T)}' deserialized to null."),
_ => value
};
return (T)parsed;
}
}
@@ -0,0 +1,119 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Holidays;
using Baya.Application.Models.Common;
using Baya.Application.Models.Holidays;
using Baya.Domain.Entities.Holidays;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Services.Holidays;
/// <summary>
/// Reads the seeded <c>iranian_holidays</c> table (lookups cached) to answer holiday/bank-closure
/// questions and shift a date to the next open bank day. The Iranian banking weekend is Friday.
/// </summary>
internal sealed class HolidayCalendarService(ApplicationDbContext db, ICacheService cache) : IHolidayCalendar
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(6);
// Payouts must not schedule past a bounded horizon even if the calendar is misconfigured.
private const int MaxLookaheadDays = 60;
private static string HolidayKey(DateOnly date) => $"holiday:is_holiday:{date:yyyy-MM-dd}";
private static string BankClosedKey(DateOnly date) => $"holiday:bank_closed:{date:yyyy-MM-dd}";
public ValueTask<bool> IsHoliday(DateOnly date, CancellationToken cancellationToken = default) =>
cache.GetOrCreateAsync(
HolidayKey(date),
async ct => await db.Set<IranianHoliday>().AsNoTracking().AnyAsync(h => h.HolidayDate == date, ct),
CacheTtl,
cancellationToken);
public ValueTask<bool> IsBankClosed(DateOnly date, CancellationToken cancellationToken = default) =>
cache.GetOrCreateAsync(
BankClosedKey(date),
async ct => await db.Set<IranianHoliday>().AsNoTracking().AnyAsync(h => h.HolidayDate == date && h.IsBankClosed, ct),
CacheTtl,
cancellationToken);
public async ValueTask<DateOnly> NextBusinessDay(DateOnly date, CancellationToken cancellationToken = default)
{
var candidate = date;
for (var i = 0; i <= MaxLookaheadDays; i++)
{
if (!IsBankWeekend(candidate) && !await IsBankClosed(candidate, cancellationToken))
return candidate;
candidate = candidate.AddDays(1);
}
throw new InvalidOperationException(
$"No open bank day found within {MaxLookaheadDays} days of {date:yyyy-MM-dd} — the holiday calendar is likely misconfigured.");
}
public async ValueTask<PagedResult<HolidayDto>> ListAsync(DateOnly? from, DateOnly? to, int page, int pageSize, CancellationToken cancellationToken = default)
{
var query = db.Set<IranianHoliday>().AsNoTracking().AsQueryable();
if (from is { } f)
query = query.Where(h => h.HolidayDate >= f);
if (to is { } t)
query = query.Where(h => h.HolidayDate <= t);
query = query.OrderBy(h => h.HolidayDate);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(h => new HolidayDto(h.Id, h.HolidayDate, h.NameFa, h.Type, h.IsBankClosed))
.ToListAsync(cancellationToken);
return new PagedResult<HolidayDto>(items, total, page, pageSize);
}
public async ValueTask UpsertAsync(DateOnly date, string nameFa, string type, bool isBankClosed, CancellationToken cancellationToken = default)
{
var existing = await db.Set<IranianHoliday>().FirstOrDefaultAsync(h => h.HolidayDate == date, cancellationToken);
if (existing is null)
{
db.Set<IranianHoliday>().Add(new IranianHoliday
{
HolidayDate = date,
NameFa = nameFa,
Type = type,
IsBankClosed = isBankClosed
});
}
else
{
existing.NameFa = nameFa;
existing.Type = type;
existing.IsBankClosed = isBankClosed;
}
await db.SaveChangesAsync(cancellationToken);
await Evict(date, cancellationToken);
}
public async ValueTask<bool> DeleteAsync(DateOnly date, CancellationToken cancellationToken = default)
{
var existing = await db.Set<IranianHoliday>().FirstOrDefaultAsync(h => h.HolidayDate == date, cancellationToken);
if (existing is null)
return false;
db.Set<IranianHoliday>().Remove(existing);
await db.SaveChangesAsync(cancellationToken);
await Evict(date, cancellationToken);
return true;
}
// Iranian banks are closed on Fridays; Thursday is treated as a business day.
private static bool IsBankWeekend(DateOnly date) => date.DayOfWeek == DayOfWeek.Friday;
private async ValueTask Evict(DateOnly date, CancellationToken cancellationToken)
{
await cache.RemoveAsync(HolidayKey(date), cancellationToken);
await cache.RemoveAsync(BankClosedKey(date), cancellationToken);
}
}
@@ -0,0 +1,33 @@
#nullable enable
using Baya.Application.Contracts.Common;
using NotificationMessage = Baya.Application.Contracts.Common.Notification;
using NotificationEntity = Baya.Domain.Entities.Notifications.Notification;
namespace Baya.Infrastructure.Persistence.Services.Notifications;
/// <summary>
/// Real in-app implementation of <see cref="INotificationDispatcher"/> — supersedes the b0 log/no-op
/// stub. It writes a <c>notifications</c> row for the in-app channel. SMS/push are deferred behind this
/// same seam; those channels are no-ops for now so callers use <see cref="DispatchAsync"/> unchanged.
/// </summary>
internal sealed class InAppNotificationDispatcher(ApplicationDbContext db, IDateTimeProvider dateTimeProvider) : INotificationDispatcher
{
public async ValueTask DispatchAsync(NotificationMessage notification, CancellationToken cancellationToken = default)
{
if (notification.Channel != NotificationChannel.InApp)
return;
db.Set<NotificationEntity>().Add(new NotificationEntity
{
UserId = notification.RecipientUserId,
Type = notification.Type,
Title = notification.Title,
Body = notification.Body,
DataJson = notification.DataJson,
IsRead = false,
CreatedAt = dateTimeProvider.UtcNow
});
await db.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,50 @@
#nullable enable
using Baya.Application.Contracts.Notifications;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Notifications;
/// <summary>
/// The scheduling seam for the notification retention job (mock = an in-process interval runner). It
/// periodically hard-deletes read notifications older than the retention window; unread notifications are
/// never deleted. Real Hangfire/Quartz is deferred — swapping it in is a registration change here.
/// </summary>
internal sealed class NotificationRetentionHostedService(
IServiceScopeFactory scopeFactory,
ILogger<NotificationRetentionHostedService> logger) : BackgroundService
{
private const int RetentionDays = 90;
private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Run once at startup, then on the interval.
await PurgeSafely(stoppingToken);
using var timer = new PeriodicTimer(Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
await PurgeSafely(stoppingToken);
}
private async Task PurgeSafely(CancellationToken cancellationToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<INotificationService>();
var removed = await notifications.PurgeOldReadAsync(RetentionDays, cancellationToken);
if (removed > 0)
logger.LogInformation("Notification retention purged {Count} read notifications older than {Days}d", removed, RetentionDays);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Host is shutting down — expected, don't log as an error.
}
catch (Exception ex)
{
logger.LogError(ex, "Notification retention purge failed");
}
}
}
@@ -0,0 +1,88 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Notifications;
using Baya.Application.Models.Common;
using Baya.Application.Models.Notifications;
using Microsoft.EntityFrameworkCore;
using NotificationEntity = Baya.Domain.Entities.Notifications.Notification;
namespace Baya.Infrastructure.Persistence.Services.Notifications;
/// <summary>
/// Reads and per-user commands over <c>notifications</c>. Every method is scoped to the passed
/// <c>userId</c> (the authenticated caller). Retention hard-deletes read notifications past the window
/// and never touches unread ones.
/// </summary>
internal sealed class NotificationService(ApplicationDbContext db, IDateTimeProvider dateTimeProvider) : INotificationService
{
public async ValueTask<PagedResult<NotificationDto>> ListMineAsync(int userId, int page, int pageSize, CancellationToken cancellationToken = default)
{
var query = db.Set<NotificationEntity>()
.AsNoTracking()
.Where(n => n.UserId == userId)
// Unread first, then newest-first via the monotonic identity (deterministic, no timestamp ties).
.OrderBy(n => n.IsRead)
.ThenByDescending(n => n.Id);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(n => new NotificationDto(n.Id, n.Type, n.Title, n.Body, n.DataJson, n.IsRead, n.ReadAt, n.CreatedAt))
.ToListAsync(cancellationToken);
return new PagedResult<NotificationDto>(items, total, page, pageSize);
}
public ValueTask<int> GetUnreadCountAsync(int userId, CancellationToken cancellationToken = default) =>
new(db.Set<NotificationEntity>().AsNoTracking().CountAsync(n => n.UserId == userId && !n.IsRead, cancellationToken));
public async ValueTask<bool> MarkReadAsync(int userId, long notificationId, CancellationToken cancellationToken = default)
{
var notification = await db.Set<NotificationEntity>()
.FirstOrDefaultAsync(n => n.Id == notificationId && n.UserId == userId, cancellationToken);
if (notification is null)
return false;
if (!notification.IsRead)
{
notification.IsRead = true;
notification.ReadAt = dateTimeProvider.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
return true;
}
public async ValueTask<int> MarkAllReadAsync(int userId, CancellationToken cancellationToken = default)
{
var now = dateTimeProvider.UtcNow;
return await db.Set<NotificationEntity>()
.Where(n => n.UserId == userId && !n.IsRead)
.ExecuteUpdateAsync(
s => s.SetProperty(n => n.IsRead, true).SetProperty(n => n.ReadAt, now),
cancellationToken);
}
public async ValueTask<int> PurgeOldReadAsync(int retentionDays, CancellationToken cancellationToken = default)
{
var cutoff = dateTimeProvider.UtcNow.AddDays(-retentionDays);
// Read-only rows are the only purge candidates (unread is never deleted). The age cutoff is
// applied in memory so the delete is a single id-keyed statement that translates on every
// provider; the candidate set is bounded (only read notifications).
var readRows = await db.Set<NotificationEntity>()
.Where(n => n.IsRead)
.Select(n => new { n.Id, n.CreatedAt })
.ToListAsync(cancellationToken);
var expiredIds = readRows.Where(n => n.CreatedAt < cutoff).Select(n => n.Id).ToList();
if (expiredIds.Count == 0)
return 0;
return await db.Set<NotificationEntity>()
.Where(n => expiredIds.Contains(n.Id))
.ExecuteDeleteAsync(cancellationToken);
}
}
@@ -0,0 +1,99 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Common;
using Baya.Application.Models.SupportAlerts;
using Baya.Domain.Entities.SupportAlerts;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Services.SupportAlerts;
/// <summary>
/// Internal support-alert worklist store. Never exposed on a user-facing route. Status is forward-only:
/// an alert can be assigned or resolved from <c>open</c>, and resolved from <c>assigned</c>, but a
/// resolved alert is terminal.
/// </summary>
internal sealed class SupportAlertService(ApplicationDbContext db, IDateTimeProvider dateTimeProvider) : ISupportAlertService
{
public async ValueTask<long> RaiseAsync(
string type,
string entityType,
string entityId,
string severity,
long? bookingId = null,
long? reviewId = null,
CancellationToken cancellationToken = default)
{
var alert = new SupportAlert
{
Type = type,
EntityType = entityType,
EntityId = entityId,
Severity = severity,
Status = SupportAlertStatus.Open,
BookingId = bookingId,
ReviewId = reviewId
};
db.Set<SupportAlert>().Add(alert);
await db.SaveChangesAsync(cancellationToken);
return alert.Id;
}
public async ValueTask<bool> AssignAsync(long alertId, int ownerUserId, CancellationToken cancellationToken = default)
{
var alert = await db.Set<SupportAlert>().FirstOrDefaultAsync(a => a.Id == alertId, cancellationToken);
if (alert is null || alert.Status == SupportAlertStatus.Resolved)
return false;
alert.OwnerUserId = ownerUserId;
alert.Status = SupportAlertStatus.Assigned;
await db.SaveChangesAsync(cancellationToken);
return true;
}
public async ValueTask<bool> ResolveAsync(long alertId, string note, CancellationToken cancellationToken = default)
{
var alert = await db.Set<SupportAlert>().FirstOrDefaultAsync(a => a.Id == alertId, cancellationToken);
if (alert is null || alert.Status == SupportAlertStatus.Resolved)
return false;
alert.Status = SupportAlertStatus.Resolved;
alert.ResolutionNote = note;
alert.ResolvedAt = dateTimeProvider.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return true;
}
public async ValueTask<PagedResult<SupportAlertDto>> ListAsync(
string? type,
string? status,
int? ownerUserId,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = db.Set<SupportAlert>().AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(type))
query = query.Where(a => a.Type == type);
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(a => a.Status == status);
if (ownerUserId is { } owner)
query = query.Where(a => a.OwnerUserId == owner);
// Id is a monotonic identity → newest-first and deterministic (no timestamp ties).
query = query.OrderByDescending(a => a.Id);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(a => new SupportAlertDto(
a.Id, a.Type, a.Severity, a.Status, a.EntityType, a.EntityId,
a.BookingId, a.ReviewId, a.OwnerUserId, a.ResolutionNote, a.ResolvedAt, a.CreatedAt))
.ToListAsync(cancellationToken);
return new PagedResult<SupportAlertDto>(items, total, page, pageSize);
}
}