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:
-22
@@ -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;
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
|
||||
+4
@@ -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" />
|
||||
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
}
|
||||
+57
@@ -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();
|
||||
}
|
||||
}
|
||||
+50
@@ -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();
|
||||
}
|
||||
}
|
||||
+26
@@ -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();
|
||||
}
|
||||
}
|
||||
+10
@@ -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);
|
||||
}
|
||||
+31
@@ -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.
|
||||
}
|
||||
}
|
||||
+113
-11
@@ -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;
|
||||
}
|
||||
|
||||
+871
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+308
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+487
@@ -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")
|
||||
|
||||
+29
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
+113
@@ -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;
|
||||
}
|
||||
}
|
||||
+119
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+50
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -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);
|
||||
}
|
||||
}
|
||||
+99
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user