3 blocker phases
This commit is contained in:
@@ -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);
|
||||
|
||||
+6
-2
@@ -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 ~7–10-business-day customer ETA is stamped on the actual refund; the preview leaves it null.
|
||||
null,
|
||||
|
||||
+63
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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>>;
|
||||
+63
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
|
||||
+55
-14
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user