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:
+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