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