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,15 @@
namespace Baya.Application.Common;
/// <summary>Normalises paging inputs so every list handler clamps <c>page</c>/<c>page_size</c> the same way.</summary>
public static class Pagination
{
public const int MaxPageSize = 100;
public const int DefaultPageSize = 50;
public static (int Page, int PageSize) Normalize(int page, int pageSize)
{
var normalizedPage = page < 1 ? 1 : page;
var normalizedSize = pageSize < 1 ? DefaultPageSize : Math.Min(pageSize, MaxPageSize);
return (normalizedPage, normalizedSize);
}
}
@@ -0,0 +1,11 @@
namespace Baya.Application.Contracts.Analytics;
/// <summary>
/// Fire-and-forget behavioural/analytics event sink. Emission must never fail or slow the caller's
/// operation — a sink error is logged and swallowed. NEVER route compliance-relevant facts here; those
/// go to the audit trail. Mock inserts a <c>system_events</c> row; the real path pipes to a warehouse.
/// </summary>
public interface IAnalyticsSink
{
ValueTask EmitAsync(string name, object props, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,27 @@
#nullable enable
using Baya.Application.Models.Audit;
using Baya.Application.Models.Common;
namespace Baya.Application.Contracts.Audit;
/// <summary>
/// Explicit append-only audit writer, for recording a state change that has no tracked-entity row diff
/// (row-level changes on auditable entities are captured automatically by the SaveChanges interceptor).
/// The trail is immutable — there is no update or delete path.
/// </summary>
public interface IAuditLogger
{
ValueTask WriteAsync(
string entityType,
string entityId,
string action,
IReadOnlyDictionary<string, object?>? changedFields = null,
CancellationToken cancellationToken = default);
ValueTask<PagedResult<AuditLogDto>> GetTrailAsync(
string entityType,
string entityId,
int page,
int pageSize,
CancellationToken cancellationToken = default);
}
@@ -1,3 +1,4 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
@@ -11,20 +12,25 @@ public enum NotificationChannel
Push
}
/// <summary>A notification to dispatch to a recipient over one channel.</summary>
/// <summary>A notification to mint for a recipient over one channel.</summary>
/// <param name="RecipientUserId">The target user.</param>
/// <param name="Channel">Delivery channel.</param>
/// <param name="Type">Stable type code driving front-end rendering/deep-link (e.g. <c>booking_confirmed</c>).</param>
/// <param name="Title">Short title/subject.</param>
/// <param name="Body">Message body (no secrets/PII in logs).</param>
/// <param name="Body">Optional message body (no secrets/PII in logs).</param>
/// <param name="DataJson">Optional typed, versioned deep-link payload — a contract, not an arbitrary blob.</param>
/// <param name="Channel">Delivery channel (in-app now; SMS/push deferred).</param>
public sealed record Notification(
int RecipientUserId,
NotificationChannel Channel,
string Type,
string Title,
string Body);
string? Body = null,
string? DataJson = null,
NotificationChannel Channel = NotificationChannel.InApp);
/// <summary>
/// Seam for emitting notifications from domains like booking and payments. The mock logs/no-ops; the
/// real in-app write lands in backend-phase-15, with SMS/push added behind the same interface.
/// Seam for minting notifications from domains like booking, payments, and reviews. The real in-app
/// implementation writes a <c>notifications</c> row; SMS/push channels are added later behind this same
/// interface, so callers use <see cref="DispatchAsync"/> unchanged.
/// </summary>
public interface INotificationDispatcher
{
@@ -0,0 +1,28 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Configuration;
namespace Baya.Application.Contracts.Configuration;
/// <summary>
/// Typed, cached accessor for runtime business parameters stored as rows in <c>platform_configs</c>.
/// Money-critical constants (commission %, VAT, deadlines, cancellation tiers) are read from here at
/// compute time — never hardcoded. Every write is audited in the same transaction.
/// </summary>
public interface IPlatformConfig
{
/// <summary>Reads a config value and parses it to <typeparamref name="T"/> per the row's <c>data_type</c> (cached).</summary>
ValueTask<T> GetConfig<T>(string key, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing config row (and writes an audit entry, in one transaction) then evicts the cache.
/// Returns <c>false</c> if the key does not exist. Changing a rate must never retroactively alter an
/// already-computed amount — later phases snapshot the rate onto the priced row at compute time.
/// </summary>
ValueTask<bool> SetConfig(string key, string value, CancellationToken cancellationToken = default);
ValueTask<PagedResult<PlatformConfigDto>> ListAsync(int page, int pageSize, CancellationToken cancellationToken = default);
/// <summary>The audited change history for a single config key (from the append-only audit trail).</summary>
ValueTask<PagedResult<ConfigChangeDto>> GetConfigChangeHistory(string key, int page, int pageSize, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,29 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Holidays;
namespace Baya.Application.Contracts.Holidays;
/// <summary>
/// The Iranian holiday calendar seam. Lookups are cached. Payout scheduling (later phase) calls
/// <see cref="NextBusinessDay"/> to shift a payout off bank-closed days. Mock reads the seeded
/// <c>iranian_holidays</c> table; the real path syncs an external banking-holiday feed.
/// </summary>
public interface IHolidayCalendar
{
ValueTask<bool> IsHoliday(DateOnly date, CancellationToken cancellationToken = default);
/// <summary>True if PAYA/SATNA banks are closed that day (a seeded bank-closed holiday).</summary>
ValueTask<bool> IsBankClosed(DateOnly date, CancellationToken cancellationToken = default);
/// <summary>The next day banks are open — skips bank-closed holidays and the Iranian banking weekend (Friday).</summary>
ValueTask<DateOnly> NextBusinessDay(DateOnly date, CancellationToken cancellationToken = default);
ValueTask<PagedResult<HolidayDto>> ListAsync(DateOnly? from, DateOnly? to, int page, int pageSize, CancellationToken cancellationToken = default);
/// <summary>Inserts or updates the holiday for <paramref name="date"/> then evicts the cached lookups for it.</summary>
ValueTask UpsertAsync(DateOnly date, string nameFa, string type, bool isBankClosed, CancellationToken cancellationToken = default);
/// <summary>Deletes the holiday for <paramref name="date"/>; returns <c>false</c> if none existed.</summary>
ValueTask<bool> DeleteAsync(DateOnly date, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,26 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Notifications;
namespace Baya.Application.Contracts.Notifications;
/// <summary>
/// Reads and per-user commands over the in-app notification store. Every operation is tenant-scoped to
/// the passed <c>userId</c> (always the authenticated caller — never a body-supplied id). Minting a new
/// notification is done through <see cref="Common.INotificationDispatcher"/>, not here.
/// </summary>
public interface INotificationService
{
/// <summary>The caller's notifications, unread-first then newest-first.</summary>
ValueTask<PagedResult<NotificationDto>> ListMineAsync(int userId, int page, int pageSize, CancellationToken cancellationToken = default);
ValueTask<int> GetUnreadCountAsync(int userId, CancellationToken cancellationToken = default);
/// <summary>Marks one of the caller's notifications read; returns <c>false</c> if it isn't theirs or doesn't exist.</summary>
ValueTask<bool> MarkReadAsync(int userId, long notificationId, CancellationToken cancellationToken = default);
/// <summary>Marks all of the caller's unread notifications read; returns the number flipped.</summary>
ValueTask<int> MarkAllReadAsync(int userId, CancellationToken cancellationToken = default);
/// <summary>Hard-deletes read notifications older than <paramref name="retentionDays"/>; never touches unread. Returns the count removed.</summary>
ValueTask<int> PurgeOldReadAsync(int retentionDays, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,37 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.SupportAlerts;
namespace Baya.Application.Contracts.SupportAlerts;
/// <summary>
/// Internal support-alert worklist. <see cref="RaiseAsync"/> is called by later review/EVV/verification/
/// payment flows. Alerts are staff-only — never surfaced on a user-facing endpoint. The polymorphic
/// <c>(entityType, entityId)</c> is validated at the application layer; the typed FK is preferred when
/// the subject is a booking or review.
/// </summary>
public interface ISupportAlertService
{
ValueTask<long> RaiseAsync(
string type,
string entityType,
string entityId,
string severity,
long? bookingId = null,
long? reviewId = null,
CancellationToken cancellationToken = default);
/// <summary>Assigns an open alert to an owner (open → assigned). Returns <c>false</c> if the alert is missing or already resolved.</summary>
ValueTask<bool> AssignAsync(long alertId, int ownerUserId, CancellationToken cancellationToken = default);
/// <summary>Resolves an alert with a note (→ resolved). Returns <c>false</c> if the alert is missing or already resolved.</summary>
ValueTask<bool> ResolveAsync(long alertId, string note, CancellationToken cancellationToken = default);
ValueTask<PagedResult<SupportAlertDto>> ListAsync(
string? type,
string? status,
int? ownerUserId,
int page,
int pageSize,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,18 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Audit;
using Baya.Application.Models.Audit;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Audit.Queries.GetAuditTrail;
internal sealed class GetAuditTrailQueryHandler(IAuditLogger auditLogger)
: IRequestHandler<GetAuditTrailQuery, OperationResult<PagedResult<AuditLogDto>>>
{
public async ValueTask<OperationResult<PagedResult<AuditLogDto>>> Handle(GetAuditTrailQuery request, CancellationToken cancellationToken)
{
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var result = await auditLogger.GetTrailAsync(request.EntityType, request.EntityId, page, pageSize, cancellationToken);
return OperationResult<PagedResult<AuditLogDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Audit;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Audit.Queries.GetAuditTrail;
public record GetAuditTrailQuery(string EntityType, string EntityId, int Page = 1, int PageSize = 50)
: IRequest<OperationResult<PagedResult<AuditLogDto>>>;
@@ -0,0 +1,18 @@
using Baya.Application.Contracts.Configuration;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Configuration.Commands.UpdatePlatformConfig;
internal sealed class UpdatePlatformConfigCommandHandler(IPlatformConfig platformConfig)
: IRequestHandler<UpdatePlatformConfigCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(UpdatePlatformConfigCommand request, CancellationToken cancellationToken)
{
var updated = await platformConfig.SetConfig(request.Key, request.Value, cancellationToken);
return updated
? OperationResult<bool>.SuccessResult(true)
: OperationResult<bool>.NotFoundResult($"Config key '{request.Key}' does not exist.");
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Configuration.Commands.UpdatePlatformConfig;
public sealed class UpdatePlatformConfigCommandValidator : AbstractValidator<UpdatePlatformConfigCommand>
{
public UpdatePlatformConfigCommandValidator()
{
RuleFor(x => x.Key).NotEmpty().MaximumLength(100);
RuleFor(x => x.Value).NotNull();
}
}
@@ -0,0 +1,7 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Configuration.Commands.UpdatePlatformConfig;
public record UpdatePlatformConfigCommand(string Key, string Value)
: IRequest<OperationResult<bool>>;
@@ -0,0 +1,18 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Models.Common;
using Baya.Application.Models.Configuration;
using Mediator;
namespace Baya.Application.Features.Configuration.Queries.GetConfigChangeHistory;
internal sealed class GetConfigChangeHistoryQueryHandler(IPlatformConfig platformConfig)
: IRequestHandler<GetConfigChangeHistoryQuery, OperationResult<PagedResult<ConfigChangeDto>>>
{
public async ValueTask<OperationResult<PagedResult<ConfigChangeDto>>> Handle(GetConfigChangeHistoryQuery request, CancellationToken cancellationToken)
{
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var result = await platformConfig.GetConfigChangeHistory(request.Key, page, pageSize, cancellationToken);
return OperationResult<PagedResult<ConfigChangeDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Configuration;
using Mediator;
namespace Baya.Application.Features.Configuration.Queries.GetConfigChangeHistory;
public record GetConfigChangeHistoryQuery(string Key, int Page = 1, int PageSize = 50)
: IRequest<OperationResult<PagedResult<ConfigChangeDto>>>;
@@ -0,0 +1,18 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Models.Common;
using Baya.Application.Models.Configuration;
using Mediator;
namespace Baya.Application.Features.Configuration.Queries.ListPlatformConfigs;
internal sealed class ListPlatformConfigsQueryHandler(IPlatformConfig platformConfig)
: IRequestHandler<ListPlatformConfigsQuery, OperationResult<PagedResult<PlatformConfigDto>>>
{
public async ValueTask<OperationResult<PagedResult<PlatformConfigDto>>> Handle(ListPlatformConfigsQuery request, CancellationToken cancellationToken)
{
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var result = await platformConfig.ListAsync(page, pageSize, cancellationToken);
return OperationResult<PagedResult<PlatformConfigDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Configuration;
using Mediator;
namespace Baya.Application.Features.Configuration.Queries.ListPlatformConfigs;
public record ListPlatformConfigsQuery(int Page = 1, int PageSize = 50)
: IRequest<OperationResult<PagedResult<PlatformConfigDto>>>;
@@ -0,0 +1,18 @@
using Baya.Application.Contracts.Holidays;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Holidays.Commands.DeleteHoliday;
internal sealed class DeleteHolidayCommandHandler(IHolidayCalendar holidayCalendar)
: IRequestHandler<DeleteHolidayCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(DeleteHolidayCommand request, CancellationToken cancellationToken)
{
var deleted = await holidayCalendar.DeleteAsync(request.HolidayDate, cancellationToken);
return deleted
? OperationResult<bool>.SuccessResult(true)
: OperationResult<bool>.NotFoundResult($"No holiday exists on {request.HolidayDate:yyyy-MM-dd}.");
}
}
@@ -0,0 +1,6 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Holidays.Commands.DeleteHoliday;
public record DeleteHolidayCommand(DateOnly HolidayDate) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,15 @@
using Baya.Application.Contracts.Holidays;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Holidays.Commands.UpsertHoliday;
internal sealed class UpsertHolidayCommandHandler(IHolidayCalendar holidayCalendar)
: IRequestHandler<UpsertHolidayCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(UpsertHolidayCommand request, CancellationToken cancellationToken)
{
await holidayCalendar.UpsertAsync(request.HolidayDate, request.NameFa, request.Type, request.IsBankClosed, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,16 @@
using Baya.Domain.Entities.Holidays;
using FluentValidation;
namespace Baya.Application.Features.Holidays.Commands.UpsertHoliday;
public sealed class UpsertHolidayCommandValidator : AbstractValidator<UpsertHolidayCommand>
{
public UpsertHolidayCommandValidator()
{
RuleFor(x => x.HolidayDate).NotEqual(default(DateOnly));
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(200);
RuleFor(x => x.Type)
.Must(t => t is HolidayType.Official or HolidayType.Religious or HolidayType.National)
.WithMessage($"Type must be one of: {HolidayType.Official}, {HolidayType.Religious}, {HolidayType.National}.");
}
}
@@ -0,0 +1,7 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Holidays.Commands.UpsertHoliday;
public record UpsertHolidayCommand(DateOnly HolidayDate, string NameFa, string Type, bool IsBankClosed)
: IRequest<OperationResult<bool>>;
@@ -0,0 +1,18 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Holidays;
using Baya.Application.Models.Common;
using Baya.Application.Models.Holidays;
using Mediator;
namespace Baya.Application.Features.Holidays.Queries.ListHolidays;
internal sealed class ListHolidaysQueryHandler(IHolidayCalendar holidayCalendar)
: IRequestHandler<ListHolidaysQuery, OperationResult<PagedResult<HolidayDto>>>
{
public async ValueTask<OperationResult<PagedResult<HolidayDto>>> Handle(ListHolidaysQuery request, CancellationToken cancellationToken)
{
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var result = await holidayCalendar.ListAsync(request.From, request.To, page, pageSize, cancellationToken);
return OperationResult<PagedResult<HolidayDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,9 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Holidays;
using Mediator;
namespace Baya.Application.Features.Holidays.Queries.ListHolidays;
public record ListHolidaysQuery(DateOnly? From = null, DateOnly? To = null, int Page = 1, int PageSize = 50)
: IRequest<OperationResult<PagedResult<HolidayDto>>>;
@@ -0,0 +1,19 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Notifications;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Notifications.Commands.MarkAllRead;
internal sealed class MarkAllReadCommandHandler(INotificationService notifications, ICurrentUser currentUser)
: IRequestHandler<MarkAllReadCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(MarkAllReadCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<bool>.FailureResult("User", "Not authenticated.");
await notifications.MarkAllReadAsync(userId, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,6 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Notifications.Commands.MarkAllRead;
public record MarkAllReadCommand : IRequest<OperationResult<bool>>;
@@ -0,0 +1,22 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Notifications;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Notifications.Commands.MarkNotificationRead;
internal sealed class MarkNotificationReadCommandHandler(INotificationService notifications, ICurrentUser currentUser)
: IRequestHandler<MarkNotificationReadCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(MarkNotificationReadCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<bool>.FailureResult("User", "Not authenticated.");
var marked = await notifications.MarkReadAsync(userId, request.NotificationId, cancellationToken);
return marked
? OperationResult<bool>.SuccessResult(true)
: OperationResult<bool>.NotFoundResult($"Notification {request.NotificationId} was not found.");
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace Baya.Application.Features.Notifications.Commands.MarkNotificationRead;
public sealed class MarkNotificationReadCommandValidator : AbstractValidator<MarkNotificationReadCommand>
{
public MarkNotificationReadCommandValidator()
{
RuleFor(x => x.NotificationId).GreaterThan(0);
}
}
@@ -0,0 +1,6 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Notifications.Commands.MarkNotificationRead;
public record MarkNotificationReadCommand(long NotificationId) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,19 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Notifications;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Notifications.Queries.GetUnreadCount;
internal sealed class GetUnreadCountQueryHandler(INotificationService notifications, ICurrentUser currentUser)
: IRequestHandler<GetUnreadCountQuery, OperationResult<UnreadCountResult>>
{
public async ValueTask<OperationResult<UnreadCountResult>> Handle(GetUnreadCountQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<UnreadCountResult>.FailureResult("User", "Not authenticated.");
var count = await notifications.GetUnreadCountAsync(userId, cancellationToken);
return OperationResult<UnreadCountResult>.SuccessResult(new UnreadCountResult(count));
}
}
@@ -0,0 +1,4 @@
namespace Baya.Application.Features.Notifications.Queries.GetUnreadCount;
/// <summary>Unread-notification count for the polling bell.</summary>
public record UnreadCountResult(int Count);
@@ -0,0 +1,6 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Notifications.Queries.GetUnreadCount;
public record GetUnreadCountQuery : IRequest<OperationResult<UnreadCountResult>>;
@@ -0,0 +1,22 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Notifications;
using Baya.Application.Models.Common;
using Baya.Application.Models.Notifications;
using Mediator;
namespace Baya.Application.Features.Notifications.Queries.ListMyNotifications;
internal sealed class ListMyNotificationsQueryHandler(INotificationService notifications, ICurrentUser currentUser)
: IRequestHandler<ListMyNotificationsQuery, OperationResult<PagedResult<NotificationDto>>>
{
public async ValueTask<OperationResult<PagedResult<NotificationDto>>> Handle(ListMyNotificationsQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PagedResult<NotificationDto>>.FailureResult("User", "Not authenticated.");
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var result = await notifications.ListMineAsync(userId, page, pageSize, cancellationToken);
return OperationResult<PagedResult<NotificationDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Notifications;
using Mediator;
namespace Baya.Application.Features.Notifications.Queries.ListMyNotifications;
public record ListMyNotificationsQuery(int Page = 1, int PageSize = 50)
: IRequest<OperationResult<PagedResult<NotificationDto>>>;
@@ -0,0 +1,18 @@
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.SupportAlerts.Commands.AssignSupportAlert;
internal sealed class AssignSupportAlertCommandHandler(ISupportAlertService supportAlerts)
: IRequestHandler<AssignSupportAlertCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(AssignSupportAlertCommand request, CancellationToken cancellationToken)
{
var assigned = await supportAlerts.AssignAsync(request.AlertId, request.OwnerUserId, cancellationToken);
return assigned
? OperationResult<bool>.SuccessResult(true)
: OperationResult<bool>.NotFoundResult($"Support alert {request.AlertId} was not found or is already resolved.");
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.SupportAlerts.Commands.AssignSupportAlert;
public sealed class AssignSupportAlertCommandValidator : AbstractValidator<AssignSupportAlertCommand>
{
public AssignSupportAlertCommandValidator()
{
RuleFor(x => x.AlertId).GreaterThan(0);
RuleFor(x => x.OwnerUserId).GreaterThan(0);
}
}
@@ -0,0 +1,6 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.SupportAlerts.Commands.AssignSupportAlert;
public record AssignSupportAlertCommand(long AlertId, int OwnerUserId) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,18 @@
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.SupportAlerts.Commands.ResolveSupportAlert;
internal sealed class ResolveSupportAlertCommandHandler(ISupportAlertService supportAlerts)
: IRequestHandler<ResolveSupportAlertCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(ResolveSupportAlertCommand request, CancellationToken cancellationToken)
{
var resolved = await supportAlerts.ResolveAsync(request.AlertId, request.Note, cancellationToken);
return resolved
? OperationResult<bool>.SuccessResult(true)
: OperationResult<bool>.NotFoundResult($"Support alert {request.AlertId} was not found or is already resolved.");
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.SupportAlerts.Commands.ResolveSupportAlert;
public sealed class ResolveSupportAlertCommandValidator : AbstractValidator<ResolveSupportAlertCommand>
{
public ResolveSupportAlertCommandValidator()
{
RuleFor(x => x.AlertId).GreaterThan(0);
RuleFor(x => x.Note).NotEmpty().MaximumLength(1000);
}
}
@@ -0,0 +1,6 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.SupportAlerts.Commands.ResolveSupportAlert;
public record ResolveSupportAlertCommand(long AlertId, string Note) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,18 @@
using Baya.Application.Common;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Common;
using Baya.Application.Models.SupportAlerts;
using Mediator;
namespace Baya.Application.Features.SupportAlerts.Queries.ListSupportAlerts;
internal sealed class ListSupportAlertsQueryHandler(ISupportAlertService supportAlerts)
: IRequestHandler<ListSupportAlertsQuery, OperationResult<PagedResult<SupportAlertDto>>>
{
public async ValueTask<OperationResult<PagedResult<SupportAlertDto>>> Handle(ListSupportAlertsQuery request, CancellationToken cancellationToken)
{
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var result = await supportAlerts.ListAsync(request.Type, request.Status, request.OwnerUserId, page, pageSize, cancellationToken);
return OperationResult<PagedResult<SupportAlertDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,13 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.SupportAlerts;
using Mediator;
namespace Baya.Application.Features.SupportAlerts.Queries.ListSupportAlerts;
public record ListSupportAlertsQuery(
string? Type = null,
string? Status = null,
int? OwnerUserId = null,
int Page = 1,
int PageSize = 50) : IRequest<OperationResult<PagedResult<SupportAlertDto>>>;
@@ -0,0 +1,12 @@
#nullable enable
namespace Baya.Application.Models.Audit;
/// <summary>One immutable audit-trail row.</summary>
public record AuditLogDto(
long Id,
string EntityType,
string EntityId,
string Action,
string? ChangedFieldsJson,
int? ActorUserId,
DateTimeOffset OccurredAt);
@@ -0,0 +1,4 @@
namespace Baya.Application.Models.Common;
/// <summary>Standard paginated payload: the page of <paramref name="Items"/> plus the total row count.</summary>
public record PagedResult<T>(IReadOnlyList<T> Items, int Total, int Page, int PageSize);
@@ -0,0 +1,13 @@
#nullable enable
namespace Baya.Application.Models.Configuration;
/// <summary>A runtime config row as returned to admins. <c>Value</c> is the raw string; parse per <c>DataType</c>.</summary>
public record PlatformConfigDto(string Key, string Value, string DataType, string? Description);
/// <summary>One audited change to a config key (from the append-only audit trail).</summary>
public record ConfigChangeDto(
long Id,
string Action,
string? ChangedFieldsJson,
int? ActorUserId,
DateTimeOffset OccurredAt);
@@ -0,0 +1,4 @@
namespace Baya.Application.Models.Holidays;
/// <summary>A calendar day in the Iranian holiday table.</summary>
public record HolidayDto(long Id, DateOnly HolidayDate, string NameFa, string Type, bool IsBankClosed);
@@ -0,0 +1,13 @@
#nullable enable
namespace Baya.Application.Models.Notifications;
/// <summary>An in-app notification as returned to its owner. <c>DataJson</c> is the typed deep-link payload.</summary>
public record NotificationDto(
long Id,
string Type,
string Title,
string? Body,
string? DataJson,
bool IsRead,
DateTimeOffset? ReadAt,
DateTimeOffset CreatedAt);
@@ -0,0 +1,17 @@
#nullable enable
namespace Baya.Application.Models.SupportAlerts;
/// <summary>An internal support-alert row (admin-only — never returned on a user-facing endpoint).</summary>
public record SupportAlertDto(
long Id,
string Type,
string Severity,
string Status,
string EntityType,
string EntityId,
long? BookingId,
long? ReviewId,
int? OwnerUserId,
string? ResolutionNote,
DateTimeOffset? ResolvedAt,
DateTimeOffset CreatedAt);
@@ -0,0 +1,22 @@
namespace Baya.Domain.Common;
/// <summary>
/// Marks an entity whose row-level changes are written to the append-only <c>audit_logs</c> trail by the
/// SaveChanges audit interceptor. This is distinct from <see cref="IAuditableEntity"/> (which only stamps
/// the create/modify audit <em>fields</em>): implementing <c>IAuditable</c> additionally produces an
/// immutable audit-log row per insert/update/delete. Reserve it for compliance-sensitive entities —
/// <c>platform_configs</c> is auditable so finance can prove the exact rate in effect at any moment.
/// </summary>
public interface IAuditable : IEntity
{
}
/// <summary>
/// Applied to a property of an <see cref="IAuditable"/> entity whose value must never appear in the
/// audit diff (<c>changed_fields_json</c>). The interceptor writes a redaction marker instead of the
/// plaintext — used for encrypted/PII columns.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class AuditRedactedAttribute : Attribute
{
}
@@ -0,0 +1,21 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Analytics;
/// <summary>
/// High-volume behavioural/analytics event. NOT compliance evidence — it can be sampled, dropped, or
/// exported to a warehouse at scale. Append-only; the only timestamp it needs is <see cref="OccurredAt"/>.
/// </summary>
public class SystemEvent : IEntity
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? PropsJson { get; set; }
public int? UserId { get; set; }
public DateTimeOffset OccurredAt { get; set; }
}
@@ -0,0 +1,34 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Audit;
/// <summary>
/// Immutable, append-only record of a state change on a compliance-sensitive entity. Never updated or
/// deleted in app code — it is the system of record for disputes/finance. <see cref="EntityId"/> is a
/// string so the trail is polymorphic across differently-typed primary keys.
/// </summary>
public class AuditLog : IEntity
{
public long Id { get; set; }
public string EntityType { get; set; } = string.Empty;
public string EntityId { get; set; } = string.Empty;
public string Action { get; set; } = AuditAction.Updated;
public string? ChangedFieldsJson { get; set; }
public int? ActorUserId { get; set; }
public DateTimeOffset OccurredAt { get; set; }
}
/// <summary>Stable codes for <see cref="AuditLog.Action"/>.</summary>
public static class AuditAction
{
public const string Created = "created";
public const string Updated = "updated";
public const string Deleted = "deleted";
}
@@ -0,0 +1,31 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Configuration;
/// <summary>
/// A typed key-value runtime business parameter (commission rate, VAT, deadlines…). The app parses
/// <see cref="Value"/> according to <see cref="DataType"/>. Every change is audited (the type implements
/// <see cref="IAuditable"/>); there is no soft-delete — configs are updated in place and the audit trail
/// is their history.
/// </summary>
public class PlatformConfig : BaseEntity<long>, IAuditable
{
public string Key { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string DataType { get; set; } = ConfigDataType.String;
public string? Description { get; set; }
}
/// <summary>Stable codes for <see cref="PlatformConfig.DataType"/> — tells the app how to parse the raw value.</summary>
public static class ConfigDataType
{
public const string Decimal = "decimal";
public const string Int = "int";
public const string Bool = "bool";
public const string String = "string";
public const string Json = "json";
}
@@ -0,0 +1,28 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Holidays;
/// <summary>
/// A single day in the shared Iranian official/religious/national calendar. <see cref="IsBankClosed"/>
/// drives payout date shifting — when PAYA/SATNA banks are closed a weekly payout moves to the next
/// business day. The calendar is partly movable/lunar-Hijri, so the table is maintained rather than
/// computed.
/// </summary>
public class IranianHoliday : BaseEntity<long>
{
public DateOnly HolidayDate { get; set; }
public string NameFa { get; set; } = string.Empty;
public string Type { get; set; } = HolidayType.Official;
public bool IsBankClosed { get; set; }
}
/// <summary>Stable codes for <see cref="IranianHoliday.Type"/>.</summary>
public static class HolidayType
{
public const string Official = "official";
public const string Religious = "religious";
public const string National = "national";
}
@@ -0,0 +1,30 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Notifications;
/// <summary>
/// An in-app notification for a single user. <see cref="DataJson"/> is a typed, versioned deep-link
/// payload the front-end navigates on — not an arbitrary blob. Read notifications older than 90 days are
/// hard-deleted by the retention job; unread ones are never auto-deleted.
/// </summary>
public class Notification : IEntity
{
public long Id { get; set; }
public int UserId { get; set; }
public string Type { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string? Body { get; set; }
public string? DataJson { get; set; }
public bool IsRead { get; set; }
public DateTimeOffset? ReadAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
@@ -0,0 +1,71 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.SupportAlerts;
/// <summary>
/// An internal staff worklist item (low rating, EVV no-show, expired verification, payment anomaly…).
/// NEVER user-facing — it must not appear in any user-facing endpoint, query, or join. The subject is a
/// polymorphic <c>(EntityType, EntityId)</c> validated at the application layer (no DB FK); the common
/// booking/review cases also set the typed FK. Status is forward-only: open → assigned → resolved.
/// </summary>
public class SupportAlert : BaseEntity<long>
{
public string Type { get; set; } = string.Empty;
public string Severity { get; set; } = SupportAlertSeverity.Medium;
public string Status { get; set; } = SupportAlertStatus.Open;
public string EntityType { get; set; } = string.Empty;
public string EntityId { get; set; } = string.Empty;
// Typed FK columns for the common cases. The bookings/reviews tables arrive in later phases; the FK
// constraints are added there, so no relationship is configured now (the migration stays additive-safe).
public long? BookingId { get; set; }
public long? ReviewId { get; set; }
public int? OwnerUserId { get; set; }
public string? ResolutionNote { get; set; }
public DateTimeOffset? ResolvedAt { get; set; }
}
/// <summary>Stable codes for <see cref="SupportAlert.Type"/>.</summary>
public static class SupportAlertType
{
public const string LowRating = "low_rating";
public const string EvvNoShow = "evv_no_show";
public const string EvvLocationMismatch = "evv_location_mismatch";
public const string VerificationExpired = "verification_expired";
public const string PaymentAnomaly = "payment_anomaly";
public const string FraudSignal = "fraud_signal";
public static readonly IReadOnlyList<string> All =
[
LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, PaymentAnomaly, FraudSignal
];
}
/// <summary>Stable codes for <see cref="SupportAlert.Severity"/>.</summary>
public static class SupportAlertSeverity
{
public const string Low = "low";
public const string Medium = "medium";
public const string High = "high";
public static readonly IReadOnlyList<string> All = [Low, Medium, High];
}
/// <summary>Stable codes for <see cref="SupportAlert.Status"/> (forward-only).</summary>
public static class SupportAlertStatus
{
public const string Open = "open";
public const string Assigned = "assigned";
public const string Resolved = "resolved";
public static readonly IReadOnlyList<string> All = [Open, Assigned, Resolved];
}