backend phase 1: config, reference & platform signals

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-02 01:18:00 +03:30
parent aae1ce971f
commit 2f2aec61a2
99 changed files with 6172 additions and 52 deletions
@@ -0,0 +1,40 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Analytics;
using Baya.Application.Contracts.Common;
using Baya.Domain.Entities.Analytics;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Analytics;
/// <summary>
/// Fire-and-forget analytics sink — the mock inserts a <c>system_events</c> row. A failure is logged and
/// swallowed so it can never surface to, or slow, the caller's operation. Compliance facts never come
/// here (they go to the audit trail).
/// </summary>
internal sealed class AnalyticsSink(
ApplicationDbContext db,
ICurrentUser currentUser,
IDateTimeProvider dateTimeProvider,
ILogger<AnalyticsSink> logger) : IAnalyticsSink
{
public async ValueTask EmitAsync(string name, object props, CancellationToken cancellationToken = default)
{
try
{
db.Set<SystemEvent>().Add(new SystemEvent
{
Name = name,
PropsJson = JsonSerializer.Serialize(props),
UserId = currentUser.UserId,
OccurredAt = dateTimeProvider.UtcNow
});
await db.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Analytics emit failed for event {EventName}", name);
}
}
}
@@ -0,0 +1,64 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Audit;
using Baya.Application.Contracts.Common;
using Baya.Application.Models.Audit;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Audit;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Services.Audit;
/// <summary>
/// Explicit append-only audit writer + trail reader. The trail is immutable: there is deliberately no
/// update or delete path here. Row-level diffs on auditable entities are captured automatically by the
/// SaveChanges interceptor; this contract covers state changes that have no tracked-entity diff.
/// </summary>
internal sealed class AuditLogger(
ApplicationDbContext db,
ICurrentUser currentUser,
IDateTimeProvider dateTimeProvider) : IAuditLogger
{
public async ValueTask WriteAsync(
string entityType,
string entityId,
string action,
IReadOnlyDictionary<string, object?>? changedFields = null,
CancellationToken cancellationToken = default)
{
db.Set<AuditLog>().Add(new AuditLog
{
EntityType = entityType,
EntityId = entityId,
Action = action,
ChangedFieldsJson = changedFields is null ? null : JsonSerializer.Serialize(changedFields),
ActorUserId = currentUser.UserId,
OccurredAt = dateTimeProvider.UtcNow
});
await db.SaveChangesAsync(cancellationToken);
}
public async ValueTask<PagedResult<AuditLogDto>> GetTrailAsync(
string entityType,
string entityId,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = db.Set<AuditLog>()
.AsNoTracking()
.Where(a => a.EntityType == entityType && a.EntityId == entityId)
// Id is a monotonic identity → newest-first and deterministic (no timestamp ties).
.OrderByDescending(a => a.Id);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(a => new AuditLogDto(a.Id, a.EntityType, a.EntityId, a.Action, a.ChangedFieldsJson, a.ActorUserId, a.OccurredAt))
.ToListAsync(cancellationToken);
return new PagedResult<AuditLogDto>(items, total, page, pageSize);
}
}
@@ -0,0 +1,113 @@
#nullable enable
using System.Globalization;
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Models.Common;
using Baya.Application.Models.Configuration;
using Baya.Domain.Entities.Audit;
using Baya.Domain.Entities.Configuration;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Services.Configuration;
/// <summary>
/// Cached, typed accessor over <c>platform_configs</c>. Reads go through <see cref="ICacheService"/>;
/// a write updates the row (audited by the SaveChanges interceptor in the same transaction) and evicts
/// the cache key so the next read sees the new value.
/// </summary>
internal sealed class PlatformConfigService(ApplicationDbContext db, ICacheService cache) : IPlatformConfig
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(30);
private static string CacheKey(string key) => $"platform_config:{key}";
public async ValueTask<T> GetConfig<T>(string key, CancellationToken cancellationToken = default)
{
var dto = await cache.GetOrCreateAsync(
CacheKey(key),
async ct => await db.Set<PlatformConfig>()
.AsNoTracking()
.Where(c => c.Key == key)
.Select(c => new PlatformConfigDto(c.Key, c.Value, c.DataType, c.Description))
.FirstOrDefaultAsync(ct),
CacheTtl,
cancellationToken);
if (dto is null)
throw new InvalidOperationException($"Platform config key '{key}' does not exist.");
return Parse<T>(dto.Value, dto.DataType);
}
public async ValueTask<bool> SetConfig(string key, string value, CancellationToken cancellationToken = default)
{
var entity = await db.Set<PlatformConfig>().FirstOrDefaultAsync(c => c.Key == key, cancellationToken);
if (entity is null)
return false;
entity.Value = value;
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveAsync(CacheKey(key), cancellationToken);
return true;
}
public async ValueTask<PagedResult<PlatformConfigDto>> ListAsync(int page, int pageSize, CancellationToken cancellationToken = default)
{
var query = db.Set<PlatformConfig>().AsNoTracking().OrderBy(c => c.Key);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(c => new PlatformConfigDto(c.Key, c.Value, c.DataType, c.Description))
.ToListAsync(cancellationToken);
return new PagedResult<PlatformConfigDto>(items, total, page, pageSize);
}
public async ValueTask<PagedResult<ConfigChangeDto>> GetConfigChangeHistory(string key, int page, int pageSize, CancellationToken cancellationToken = default)
{
var configId = await db.Set<PlatformConfig>()
.AsNoTracking()
.Where(c => c.Key == key)
.Select(c => (long?)c.Id)
.FirstOrDefaultAsync(cancellationToken);
if (configId is null)
return new PagedResult<ConfigChangeDto>([], 0, page, pageSize);
var entityId = configId.Value.ToString(CultureInfo.InvariantCulture);
var query = db.Set<AuditLog>()
.AsNoTracking()
.Where(a => a.EntityType == nameof(PlatformConfig) && a.EntityId == entityId)
// Id is a monotonic identity → newest-first and deterministic (no timestamp ties).
.OrderByDescending(a => a.Id);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(a => new ConfigChangeDto(a.Id, a.Action, a.ChangedFieldsJson, a.ActorUserId, a.OccurredAt))
.ToListAsync(cancellationToken);
return new PagedResult<ConfigChangeDto>(items, total, page, pageSize);
}
private static T Parse<T>(string value, string dataType)
{
object parsed = dataType switch
{
ConfigDataType.Decimal => decimal.Parse(value, CultureInfo.InvariantCulture),
ConfigDataType.Int => int.Parse(value, CultureInfo.InvariantCulture),
ConfigDataType.Bool => bool.Parse(value),
ConfigDataType.Json => JsonSerializer.Deserialize<T>(value)
?? throw new InvalidOperationException($"Config value for type '{typeof(T)}' deserialized to null."),
_ => value
};
return (T)parsed;
}
}
@@ -0,0 +1,119 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Holidays;
using Baya.Application.Models.Common;
using Baya.Application.Models.Holidays;
using Baya.Domain.Entities.Holidays;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Services.Holidays;
/// <summary>
/// Reads the seeded <c>iranian_holidays</c> table (lookups cached) to answer holiday/bank-closure
/// questions and shift a date to the next open bank day. The Iranian banking weekend is Friday.
/// </summary>
internal sealed class HolidayCalendarService(ApplicationDbContext db, ICacheService cache) : IHolidayCalendar
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(6);
// Payouts must not schedule past a bounded horizon even if the calendar is misconfigured.
private const int MaxLookaheadDays = 60;
private static string HolidayKey(DateOnly date) => $"holiday:is_holiday:{date:yyyy-MM-dd}";
private static string BankClosedKey(DateOnly date) => $"holiday:bank_closed:{date:yyyy-MM-dd}";
public ValueTask<bool> IsHoliday(DateOnly date, CancellationToken cancellationToken = default) =>
cache.GetOrCreateAsync(
HolidayKey(date),
async ct => await db.Set<IranianHoliday>().AsNoTracking().AnyAsync(h => h.HolidayDate == date, ct),
CacheTtl,
cancellationToken);
public ValueTask<bool> IsBankClosed(DateOnly date, CancellationToken cancellationToken = default) =>
cache.GetOrCreateAsync(
BankClosedKey(date),
async ct => await db.Set<IranianHoliday>().AsNoTracking().AnyAsync(h => h.HolidayDate == date && h.IsBankClosed, ct),
CacheTtl,
cancellationToken);
public async ValueTask<DateOnly> NextBusinessDay(DateOnly date, CancellationToken cancellationToken = default)
{
var candidate = date;
for (var i = 0; i <= MaxLookaheadDays; i++)
{
if (!IsBankWeekend(candidate) && !await IsBankClosed(candidate, cancellationToken))
return candidate;
candidate = candidate.AddDays(1);
}
throw new InvalidOperationException(
$"No open bank day found within {MaxLookaheadDays} days of {date:yyyy-MM-dd} — the holiday calendar is likely misconfigured.");
}
public async ValueTask<PagedResult<HolidayDto>> ListAsync(DateOnly? from, DateOnly? to, int page, int pageSize, CancellationToken cancellationToken = default)
{
var query = db.Set<IranianHoliday>().AsNoTracking().AsQueryable();
if (from is { } f)
query = query.Where(h => h.HolidayDate >= f);
if (to is { } t)
query = query.Where(h => h.HolidayDate <= t);
query = query.OrderBy(h => h.HolidayDate);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(h => new HolidayDto(h.Id, h.HolidayDate, h.NameFa, h.Type, h.IsBankClosed))
.ToListAsync(cancellationToken);
return new PagedResult<HolidayDto>(items, total, page, pageSize);
}
public async ValueTask UpsertAsync(DateOnly date, string nameFa, string type, bool isBankClosed, CancellationToken cancellationToken = default)
{
var existing = await db.Set<IranianHoliday>().FirstOrDefaultAsync(h => h.HolidayDate == date, cancellationToken);
if (existing is null)
{
db.Set<IranianHoliday>().Add(new IranianHoliday
{
HolidayDate = date,
NameFa = nameFa,
Type = type,
IsBankClosed = isBankClosed
});
}
else
{
existing.NameFa = nameFa;
existing.Type = type;
existing.IsBankClosed = isBankClosed;
}
await db.SaveChangesAsync(cancellationToken);
await Evict(date, cancellationToken);
}
public async ValueTask<bool> DeleteAsync(DateOnly date, CancellationToken cancellationToken = default)
{
var existing = await db.Set<IranianHoliday>().FirstOrDefaultAsync(h => h.HolidayDate == date, cancellationToken);
if (existing is null)
return false;
db.Set<IranianHoliday>().Remove(existing);
await db.SaveChangesAsync(cancellationToken);
await Evict(date, cancellationToken);
return true;
}
// Iranian banks are closed on Fridays; Thursday is treated as a business day.
private static bool IsBankWeekend(DateOnly date) => date.DayOfWeek == DayOfWeek.Friday;
private async ValueTask Evict(DateOnly date, CancellationToken cancellationToken)
{
await cache.RemoveAsync(HolidayKey(date), cancellationToken);
await cache.RemoveAsync(BankClosedKey(date), cancellationToken);
}
}
@@ -0,0 +1,33 @@
#nullable enable
using Baya.Application.Contracts.Common;
using NotificationMessage = Baya.Application.Contracts.Common.Notification;
using NotificationEntity = Baya.Domain.Entities.Notifications.Notification;
namespace Baya.Infrastructure.Persistence.Services.Notifications;
/// <summary>
/// Real in-app implementation of <see cref="INotificationDispatcher"/> — supersedes the b0 log/no-op
/// stub. It writes a <c>notifications</c> row for the in-app channel. SMS/push are deferred behind this
/// same seam; those channels are no-ops for now so callers use <see cref="DispatchAsync"/> unchanged.
/// </summary>
internal sealed class InAppNotificationDispatcher(ApplicationDbContext db, IDateTimeProvider dateTimeProvider) : INotificationDispatcher
{
public async ValueTask DispatchAsync(NotificationMessage notification, CancellationToken cancellationToken = default)
{
if (notification.Channel != NotificationChannel.InApp)
return;
db.Set<NotificationEntity>().Add(new NotificationEntity
{
UserId = notification.RecipientUserId,
Type = notification.Type,
Title = notification.Title,
Body = notification.Body,
DataJson = notification.DataJson,
IsRead = false,
CreatedAt = dateTimeProvider.UtcNow
});
await db.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,50 @@
#nullable enable
using Baya.Application.Contracts.Notifications;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Notifications;
/// <summary>
/// The scheduling seam for the notification retention job (mock = an in-process interval runner). It
/// periodically hard-deletes read notifications older than the retention window; unread notifications are
/// never deleted. Real Hangfire/Quartz is deferred — swapping it in is a registration change here.
/// </summary>
internal sealed class NotificationRetentionHostedService(
IServiceScopeFactory scopeFactory,
ILogger<NotificationRetentionHostedService> logger) : BackgroundService
{
private const int RetentionDays = 90;
private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Run once at startup, then on the interval.
await PurgeSafely(stoppingToken);
using var timer = new PeriodicTimer(Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
await PurgeSafely(stoppingToken);
}
private async Task PurgeSafely(CancellationToken cancellationToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<INotificationService>();
var removed = await notifications.PurgeOldReadAsync(RetentionDays, cancellationToken);
if (removed > 0)
logger.LogInformation("Notification retention purged {Count} read notifications older than {Days}d", removed, RetentionDays);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Host is shutting down — expected, don't log as an error.
}
catch (Exception ex)
{
logger.LogError(ex, "Notification retention purge failed");
}
}
}
@@ -0,0 +1,88 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Notifications;
using Baya.Application.Models.Common;
using Baya.Application.Models.Notifications;
using Microsoft.EntityFrameworkCore;
using NotificationEntity = Baya.Domain.Entities.Notifications.Notification;
namespace Baya.Infrastructure.Persistence.Services.Notifications;
/// <summary>
/// Reads and per-user commands over <c>notifications</c>. Every method is scoped to the passed
/// <c>userId</c> (the authenticated caller). Retention hard-deletes read notifications past the window
/// and never touches unread ones.
/// </summary>
internal sealed class NotificationService(ApplicationDbContext db, IDateTimeProvider dateTimeProvider) : INotificationService
{
public async ValueTask<PagedResult<NotificationDto>> ListMineAsync(int userId, int page, int pageSize, CancellationToken cancellationToken = default)
{
var query = db.Set<NotificationEntity>()
.AsNoTracking()
.Where(n => n.UserId == userId)
// Unread first, then newest-first via the monotonic identity (deterministic, no timestamp ties).
.OrderBy(n => n.IsRead)
.ThenByDescending(n => n.Id);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(n => new NotificationDto(n.Id, n.Type, n.Title, n.Body, n.DataJson, n.IsRead, n.ReadAt, n.CreatedAt))
.ToListAsync(cancellationToken);
return new PagedResult<NotificationDto>(items, total, page, pageSize);
}
public ValueTask<int> GetUnreadCountAsync(int userId, CancellationToken cancellationToken = default) =>
new(db.Set<NotificationEntity>().AsNoTracking().CountAsync(n => n.UserId == userId && !n.IsRead, cancellationToken));
public async ValueTask<bool> MarkReadAsync(int userId, long notificationId, CancellationToken cancellationToken = default)
{
var notification = await db.Set<NotificationEntity>()
.FirstOrDefaultAsync(n => n.Id == notificationId && n.UserId == userId, cancellationToken);
if (notification is null)
return false;
if (!notification.IsRead)
{
notification.IsRead = true;
notification.ReadAt = dateTimeProvider.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
return true;
}
public async ValueTask<int> MarkAllReadAsync(int userId, CancellationToken cancellationToken = default)
{
var now = dateTimeProvider.UtcNow;
return await db.Set<NotificationEntity>()
.Where(n => n.UserId == userId && !n.IsRead)
.ExecuteUpdateAsync(
s => s.SetProperty(n => n.IsRead, true).SetProperty(n => n.ReadAt, now),
cancellationToken);
}
public async ValueTask<int> PurgeOldReadAsync(int retentionDays, CancellationToken cancellationToken = default)
{
var cutoff = dateTimeProvider.UtcNow.AddDays(-retentionDays);
// Read-only rows are the only purge candidates (unread is never deleted). The age cutoff is
// applied in memory so the delete is a single id-keyed statement that translates on every
// provider; the candidate set is bounded (only read notifications).
var readRows = await db.Set<NotificationEntity>()
.Where(n => n.IsRead)
.Select(n => new { n.Id, n.CreatedAt })
.ToListAsync(cancellationToken);
var expiredIds = readRows.Where(n => n.CreatedAt < cutoff).Select(n => n.Id).ToList();
if (expiredIds.Count == 0)
return 0;
return await db.Set<NotificationEntity>()
.Where(n => expiredIds.Contains(n.Id))
.ExecuteDeleteAsync(cancellationToken);
}
}
@@ -0,0 +1,99 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Common;
using Baya.Application.Models.SupportAlerts;
using Baya.Domain.Entities.SupportAlerts;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Services.SupportAlerts;
/// <summary>
/// Internal support-alert worklist store. Never exposed on a user-facing route. Status is forward-only:
/// an alert can be assigned or resolved from <c>open</c>, and resolved from <c>assigned</c>, but a
/// resolved alert is terminal.
/// </summary>
internal sealed class SupportAlertService(ApplicationDbContext db, IDateTimeProvider dateTimeProvider) : ISupportAlertService
{
public async ValueTask<long> RaiseAsync(
string type,
string entityType,
string entityId,
string severity,
long? bookingId = null,
long? reviewId = null,
CancellationToken cancellationToken = default)
{
var alert = new SupportAlert
{
Type = type,
EntityType = entityType,
EntityId = entityId,
Severity = severity,
Status = SupportAlertStatus.Open,
BookingId = bookingId,
ReviewId = reviewId
};
db.Set<SupportAlert>().Add(alert);
await db.SaveChangesAsync(cancellationToken);
return alert.Id;
}
public async ValueTask<bool> AssignAsync(long alertId, int ownerUserId, CancellationToken cancellationToken = default)
{
var alert = await db.Set<SupportAlert>().FirstOrDefaultAsync(a => a.Id == alertId, cancellationToken);
if (alert is null || alert.Status == SupportAlertStatus.Resolved)
return false;
alert.OwnerUserId = ownerUserId;
alert.Status = SupportAlertStatus.Assigned;
await db.SaveChangesAsync(cancellationToken);
return true;
}
public async ValueTask<bool> ResolveAsync(long alertId, string note, CancellationToken cancellationToken = default)
{
var alert = await db.Set<SupportAlert>().FirstOrDefaultAsync(a => a.Id == alertId, cancellationToken);
if (alert is null || alert.Status == SupportAlertStatus.Resolved)
return false;
alert.Status = SupportAlertStatus.Resolved;
alert.ResolutionNote = note;
alert.ResolvedAt = dateTimeProvider.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return true;
}
public async ValueTask<PagedResult<SupportAlertDto>> ListAsync(
string? type,
string? status,
int? ownerUserId,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = db.Set<SupportAlert>().AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(type))
query = query.Where(a => a.Type == type);
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(a => a.Status == status);
if (ownerUserId is { } owner)
query = query.Where(a => a.OwnerUserId == owner);
// Id is a monotonic identity → newest-first and deterministic (no timestamp ties).
query = query.OrderByDescending(a => a.Id);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(a => new SupportAlertDto(
a.Id, a.Type, a.Severity, a.Status, a.EntityType, a.EntityId,
a.BookingId, a.ReviewId, a.OwnerUserId, a.ResolutionNote, a.ResolvedAt, a.CreatedAt))
.ToListAsync(cancellationToken);
return new PagedResult<SupportAlertDto>(items, total, page, pageSize);
}
}