3 blocker phases

This commit is contained in:
hamid
2026-08-02 23:12:44 +03:30
parent 90e0cdcc34
commit 66a60ce874
38 changed files with 536 additions and 234 deletions
@@ -1,5 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Verification.Commands.ApproveVerification;
using Baya.Application.Features.Verification.Commands.RejectVerification;
using Baya.Application.Features.Verification.Commands.ReviewStep;
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
using Baya.Application.Features.Verification.Commands.SuspendVerification;
@@ -46,8 +48,26 @@ public sealed class AdminVerificationsController(ISender sender) : BaseControlle
public async Task<IActionResult> Suspend(long nurseVerificationId, AdminSuspendVerificationCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { NurseVerificationId = nurseVerificationId }, cancellationToken));
// Explicit confirmation once every required step has passed — Finalize already flipped the aggregate
// to approved as a side effect of whichever step completed last; this just re-confirms it (409 if not
// actually fully passed) so the admin UI's whole-verification "Approve" action has a real endpoint.
[HttpPost("{nurseVerificationId}/[action]")]
[ProducesOkApiResponseType<bool>]
public async Task<IActionResult> Approve(long nurseVerificationId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new AdminApproveVerificationCommand(nurseVerificationId), cancellationToken));
// Rejects the whole verification regardless of individual step outcomes (an admin override, not a
// per-step decision).
[HttpPost("{nurseVerificationId}/[action]")]
[ProducesOkApiResponseType<bool>]
public async Task<IActionResult> Reject(long nurseVerificationId, AdminRejectVerificationBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new AdminRejectVerificationCommand(nurseVerificationId, body.Reason), cancellationToken));
[HttpPost("scan_expiring")]
[ProducesOkApiResponseType<ScanExpiringResult>]
public async Task<IActionResult> ScanExpiring(ScanExpiringCredentialsCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
}
/// <summary>The whole-verification reject body (the id comes from the route).</summary>
public record AdminRejectVerificationBody(string Reason);
@@ -66,6 +66,10 @@ internal sealed class GetCancellationPolicyPreviewQueryHandler(
var channelContext = await unitOfWork.RefundRepository.GetRefundContextAsync(request.BookingId, cancellationToken);
var channel = channelContext?.GatewayType == PaymentGatewayType.Bnpl ? RefundChannel.BnplRevert : RefundChannel.PspCard;
// Whole booking vs remaining-sessions scope: whole when every session is still un-started.
var appliesTo = sessions.Count > 0 && sessions.All(s => s.Refundable) ? "whole_booking" : "remaining_sessions";
var leadTimeLabel = hoursBefore >= 24 ? "gt_24h" : hoursBefore >= 0 ? "lt_24h" : "started";
var dto = new CancellationPolicyPreviewDto(
booking.Id,
cancellable,
@@ -77,8 +81,8 @@ internal sealed class GetCancellationPolicyPreviewQueryHandler(
Str(refundableBase),
Str(platformFeeRefunded),
Str(nursePayoutRefunded),
CancellationActor.Customer,
hoursBefore >= 24 ? "at_least_24h" : "less_than_24h",
appliesTo,
leadTimeLabel,
channel,
// The BNPL ~710-business-day customer ETA is stamped on the actual refund; the preview leaves it null.
null,
@@ -0,0 +1,63 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Audit;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.ApproveVerification;
internal sealed class AdminApproveVerificationCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IAuditLogger auditLogger,
ICacheService cache,
IDateTimeProvider dateTimeProvider,
ISearchIndexMaintainer searchIndex)
: IRequestHandler<AdminApproveVerificationCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(AdminApproveVerificationCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
var verification = await unitOfWork.VerificationRepository.GetTrackedByIdAsync(request.NurseVerificationId, cancellationToken);
if (verification is null)
return OperationResult<bool>.NotFoundResult("Verification not found.");
var hasSteps = verification.Steps.Count > 0;
var allPassed = hasSteps && verification.Steps.All(s => s.Status == VerificationStepStatus.Passed);
if (!allPassed)
return OperationResult<bool>.ConflictResult("Not every required step has passed yet.");
var now = dateTimeProvider.UtcNow;
var nurseId = verification.NurseId;
var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken);
if (profile is null)
return OperationResult<bool>.NotFoundResult("Nurse profile not found.");
verification.ReviewedByAdminId = adminId;
// Idempotent: re-derives `approved` from the already-passed steps (Finalize already flipped this on
// whichever step completed last); this just records the admin's explicit confirmation.
VerificationAggregator.Finalize(verification, profile, now);
await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken);
await unitOfWork.CommitAsync();
await auditLogger.WriteAsync(
"nurse_verification",
verification.Id.ToString(),
"approve",
new Dictionary<string, object?> { ["admin_id"] = adminId },
cancellationToken);
await VerificationCache.InvalidateBadgeAsync(cache, nurseId, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,14 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.ApproveVerification;
/// <summary>
/// Explicit admin confirmation that a verification is approved. By the time every required step has
/// passed, <see cref="Baya.Application.Common.VerificationAggregator"/> has already flipped
/// <c>nurse_verifications.status</c> to <c>approved</c> as a side effect of whichever step completed last
/// (an admin decide or an automated run) — this re-runs that same aggregation (idempotent) and returns a
/// clean conflict if the case isn't actually fully passed yet (a stale client). <c>NurseVerificationId</c>
/// is route-supplied.
/// </summary>
public record AdminApproveVerificationCommand(long NurseVerificationId) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,63 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Audit;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Verification;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.RejectVerification;
internal sealed class AdminRejectVerificationCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IAuditLogger auditLogger,
ICacheService cache,
IDateTimeProvider dateTimeProvider,
ISearchIndexMaintainer searchIndex)
: IRequestHandler<AdminRejectVerificationCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(AdminRejectVerificationCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
var verification = await unitOfWork.VerificationRepository.GetTrackedByIdAsync(request.NurseVerificationId, cancellationToken);
if (verification is null)
return OperationResult<bool>.NotFoundResult("Verification not found.");
var now = dateTimeProvider.UtcNow;
var nurseId = verification.NurseId;
var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken);
if (profile is null)
return OperationResult<bool>.NotFoundResult("Nurse profile not found.");
// An explicit admin override, not a per-step derivation — deliberately not routed through
// `VerificationAggregator.Finalize` (it re-derives status purely from step outcomes, which would
// clobber this back to in_review/pending unless a step happens to already be failed).
verification.Status = VerificationStatus.Rejected;
verification.RejectedAt = now;
verification.RejectionReason = request.Reason;
verification.ApprovedAt = null;
verification.ReviewedByAdminId = adminId;
profile.MarkUnverified();
await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken);
await unitOfWork.CommitAsync();
await auditLogger.WriteAsync(
"nurse_verification",
verification.Id.ToString(),
"reject",
new Dictionary<string, object?> { ["admin_id"] = adminId, ["reason"] = request.Reason },
cancellationToken);
await VerificationCache.InvalidateBadgeAsync(cache, nurseId, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Verification.Commands.RejectVerification;
// NurseVerificationId is route-supplied (set via `command with { ... }`), so it is not validated here.
public sealed class AdminRejectVerificationCommandValidator : AbstractValidator<AdminRejectVerificationCommand>
{
public AdminRejectVerificationCommandValidator()
{
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Verification.Commands.RejectVerification;
/// <summary>
/// Rejects the whole verification regardless of individual step outcomes (e.g. a fraud red flag an admin
/// wants to act on immediately, not one step at a time) — a distinct action from deciding a single step,
/// mirroring the same terminal <c>rejected</c> path <c>VerificationAggregator</c> already takes when a step
/// fails. <c>NurseVerificationId</c> is route-supplied.
/// </summary>
public record AdminRejectVerificationCommand(long NurseVerificationId, string Reason) : IRequest<OperationResult<bool>>;
@@ -1,10 +1,14 @@
namespace Baya.Application.Models.Search;
/// <summary>
/// One family-facing search hit — a bookable variant matched in a covered area. <c>Price</c> is IRR Rials
/// as a digit string (BIGINT on the wire, never a float); <c>DistrictId</c> == null means the nurse covers
/// the whole city. <c>NurseName</c>/<c>AvatarUrl</c> are the card's identity (denormalized on the index);
/// <c>DistanceKm</c> is optional — null until the index carries the searched coordinate.
/// One family-facing search hit — **one card per nurse**, not one row per matching variant (a nurse
/// matching several variants/areas for the same query previously surfaced as several hits — see phase 10
/// of <c>mvp/blocker-phases</c>). <c>VariantId</c>/<c>Price</c>/<c>PriceUnit</c> describe the nurse's
/// cheapest matching variant (the card's "from X" price); <c>MatchingServiceCount</c> is how many distinct
/// variants of hers matched, so the UI can disclose "+N more" rather than implying she has only one. Price
/// is IRR Rials as a digit string (BIGINT on the wire, never a float); <c>DistrictId</c> == null means the
/// nurse covers the whole city. <c>NurseName</c>/<c>AvatarUrl</c> are the card's identity (denormalized on
/// the index); <c>DistanceKm</c> is optional — null until the index carries the searched coordinate.
/// </summary>
public record NurseSearchResultDto(
long VariantId,
@@ -20,4 +24,5 @@ public record NurseSearchResultDto(
long? DistrictId,
string NurseName,
string AvatarUrl,
double? DistanceKm);
double? DistanceKm,
int MatchingServiceCount);
@@ -12,8 +12,10 @@ namespace Baya.Infrastructure.Persistence.Services.Search;
/// The MVP <see cref="INurseSearch"/> backend — the real, production search over the maintained
/// <c>nurse_search_index</c>. It reads <b>only</b> <c>is_searchable = 1</c> rows (an unverified, suspended,
/// paused, or deactivated nurse/variant never surfaces), applies the category/city/district/gender/price
/// filters and the rating sort, and paginates. Served from the covering search index; a later
/// <c>ElasticNurseSearch</c> replaces this class behind the same interface with no caller changes.
/// filters, groups the matches down to <b>one card per nurse</b> (the index is one row per bookable
/// variant × covered area, so a nurse with several matching variants/areas would otherwise surface as
/// several hits — phase 10), and paginates over that grouped set. Served from the covering search index; a
/// later <c>ElasticNurseSearch</c> replaces this class behind the same interface with no caller changes.
/// </summary>
internal sealed class SqlNurseSearch(ApplicationDbContext db) : INurseSearch
{
@@ -42,29 +44,68 @@ internal sealed class SqlNurseSearch(ApplicationDbContext db) : INurseSearch
if (!string.IsNullOrWhiteSpace(criteria.PriceUnit))
query = query.Where(r => r.PriceUnit == criteria.PriceUnit);
var total = await query.CountAsync(cancellationToken);
// Group the filtered rows down to one entry per nurse — page size must be nurse-counted, not
// row-counted, so both the count and the Skip/Take run over this grouped set, not the raw rows.
// Plain aggregates only, projected to an anonymous type (a named record constructor here doesn't
// translate reliably) — the per-nurse distinct-variant count is derived from `candidates` below
// instead, which already carries every matching row for the paged nurses.
var grouped = query.GroupBy(r => r.NurseId).Select(g => new
{
NurseId = g.Key,
MinPrice = g.Min(r => r.Price),
AverageRating = g.Max(r => r.AverageRating),
TotalReviews = g.Max(r => r.TotalReviews),
});
var rows = await query
// Rating sort is the only MVP sort; the tiebreak on reviews then nurse_id keeps paging deterministic.
.OrderByDescending(r => r.AverageRating)
.ThenByDescending(r => r.TotalReviews)
.ThenBy(r => r.NurseId)
.ThenBy(r => r.VariantId)
var total = await grouped.CountAsync(cancellationToken);
// Rating sort is the only MVP sort; the tiebreak on reviews then nurse_id keeps paging deterministic.
var page = await grouped
.OrderByDescending(g => g.AverageRating)
.ThenByDescending(g => g.TotalReviews)
.ThenBy(g => g.NurseId)
.Skip((criteria.Page - 1) * criteria.PageSize)
.Take(criteria.PageSize)
.ToListAsync(cancellationToken);
if (page.Count == 0)
return new PagedResult<NurseSearchResultDto>([], total, criteria.Page, criteria.PageSize);
// One query for the card's representative row per paged nurse — her cheapest matching variant
// (VariantId tiebreaks a price tie so the pick is deterministic). Fetches every matching row for
// just this page's nurses (a handful of variants/areas each at MVP scale), then picks in memory —
// avoids relying on "OrderBy().First() inside a GroupBy projection", which EF/SQL Server doesn't
// translate as reliably as a plain aggregate GroupBy.
var pageNurseIds = page.Select(g => g.NurseId).ToList();
var minPriceByNurse = page.ToDictionary(g => g.NurseId, g => g.MinPrice);
var candidates = await query
.Where(r => pageNurseIds.Contains(r.NurseId))
.Select(r => new Row(
r.VariantId, r.NurseId, r.ServiceCategoryId, r.Price, r.PriceUnit, r.NurseGender,
r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId,
r.NurseName, r.AvatarUrl))
.ToListAsync(cancellationToken);
var byNurse = candidates.GroupBy(r => r.NurseId).ToDictionary(g => g.Key, g => g.ToList());
var representativeByNurse = byNurse.ToDictionary(
kv => kv.Key,
kv => kv.Value.Where(r => r.Price == minPriceByNurse[kv.Key]).OrderBy(r => r.VariantId).First());
// Distinct variants, not rows — the same variant can carry more than one matched area row (e.g. a
// district-specific row plus a whole-city row), which must not inflate "N matching services".
var matchCountByNurse = byNurse.ToDictionary(kv => kv.Key, kv => kv.Value.Select(r => r.VariantId).Distinct().Count());
// Format price to a digit string in memory (no long.ToString translation required in SQL).
// DistanceKm is null: the covering index carries no coordinate, so distance is not derivable here.
var items = rows.Select(r => new NurseSearchResultDto(
r.VariantId, r.NurseId, r.ServiceCategoryId,
r.Price.ToString(CultureInfo.InvariantCulture), r.PriceUnit, r.NurseGender,
r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId,
r.NurseName, r.AvatarUrl, null)).ToList();
var items = pageNurseIds.Select(nurseId =>
{
var r = representativeByNurse[nurseId];
return new NurseSearchResultDto(
r.VariantId, r.NurseId, r.ServiceCategoryId,
r.Price.ToString(CultureInfo.InvariantCulture), r.PriceUnit, r.NurseGender,
r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId,
r.NurseName, r.AvatarUrl, null, matchCountByNurse[nurseId]);
}).ToList();
return new PagedResult<NurseSearchResultDto>(items, total, criteria.Page, criteria.PageSize);
}
@@ -1,4 +1,5 @@
using Baya.Application.Models.Search;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Verification;
namespace Baya.Test.Foundation.Search;
@@ -118,6 +119,43 @@ public sealed class SearchIndexTests
Assert.Equal(new[] { high.NurseId, low.NurseId }, page.Items.Select(i => i.NurseId).ToArray());
}
[Fact]
public void SameNurseMultipleMatchingVariants_CollapsesToOneCardWithCheapestPriceAndMatchCount()
{
using var host = new SearchIndexTestHost();
var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 2000, host.District3Id);
Project(host, nurse);
// A second, cheaper variant in the same category — the maintainer fans it out across the nurse's
// existing areas, so it also becomes a match for the same search.
var cheaper = new NurseServiceVariant
{
NurseId = nurse.NurseId,
ServiceCategoryId = host.CategoryId,
Price = 1000,
PriceUnit = "per_day",
SessionCount = null,
DisplayName = "cheaper",
OptionSetHash = $"hash-{nurse.NurseId}-2",
IsActive = true
};
host.Db.Set<NurseServiceVariant>().Add(cheaper);
host.Db.SaveChanges();
host.Maintainer.ReindexVariantAsync(cheaper, default).GetAwaiter().GetResult();
host.Db.SaveChanges();
// Two searchable rows for the one nurse, but the search must collapse them to one card.
Assert.Equal(2, host.SearchableRowCount());
var page = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result;
Assert.Single(page.Items);
Assert.Equal(1, page.Total);
Assert.Equal(nurse.NurseId, page.Items[0].NurseId);
Assert.Equal("1000", page.Items[0].Price); // the cheaper matching variant is the representative
Assert.Equal(2, page.Items[0].MatchingServiceCount);
}
[Fact]
public void SuspendingANurseRemovesThemFromSearch()
{