backend phase 14 & frontend phase 7
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Reviews.Queries.GetReviewModerationQueue;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
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;
|
||||
|
||||
/// <summary>Admin review console: the moderation queue (defaults to <c>pending_moderation</c>), with any linked
|
||||
/// low-rating alert id. Support alerts themselves stay internal — only their id is surfaced here for triage.</summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/admin/reviews")]
|
||||
[Authorize(ConstantPolicies.DynamicPermission)]
|
||||
[Display(Description = "Admin review moderation queue")]
|
||||
public sealed class AdminReviewsController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpGet("moderation_queue")]
|
||||
[ProducesOkApiResponseType<PagedResult<ModerationQueueItemDto>>]
|
||||
public async Task<IActionResult> ModerationQueue([FromQuery] GetReviewModerationQueueQuery query, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Reviews.Commands.SubmitReview;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>
|
||||
/// The customer submits the one allowed review for a completed booking. Ownership, the completed/closed
|
||||
/// eligibility gate, and the 1:1 rule are enforced in the handler; the booking id comes from the route.
|
||||
/// </summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/bookings")]
|
||||
[Authorize]
|
||||
[Display(Description = "Submit the one review for a completed booking (customer)")]
|
||||
public sealed class BookingReviewsController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpPost("{bookingId}/review")]
|
||||
[ProducesOkApiResponseType<SubmitReviewResult>]
|
||||
public async Task<IActionResult> Review(long bookingId, SubmitReviewBody body, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(
|
||||
new SubmitReviewCommand(bookingId, body.Rating, body.Body, body.TagCodes), cancellationToken));
|
||||
|
||||
/// <summary>The review body (the booking id comes from the route).</summary>
|
||||
public record SubmitReviewBody(int Rating, string? Body, IReadOnlyList<string>? TagCodes);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Reviews.Queries.GetTagAggregates;
|
||||
using Baya.Application.Features.Reviews.Queries.ListReviewsForNurse;
|
||||
using Baya.Application.Features.Verification.Queries.GetTrustBadge;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.Application.Models.Verification;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
@@ -14,7 +17,7 @@ namespace Baya.Web.Api.Controllers.V1;
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[AllowAnonymous]
|
||||
[Display(Description = "Public nurse read surface (the verified trust badge)")]
|
||||
[Display(Description = "Public nurse read surface (the verified trust badge + published reviews)")]
|
||||
public sealed class NursesController(ISender sender) : BaseController
|
||||
{
|
||||
// Public: the verified badge exposes credential *types* held, never the encrypted numbers.
|
||||
@@ -22,4 +25,16 @@ public sealed class NursesController(ISender sender) : BaseController
|
||||
[ProducesOkApiResponseType<TrustBadgeDto>]
|
||||
public async Task<IActionResult> TrustBadge(long nurseId, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new GetVerifiedTrustBadgeQuery(nurseId), cancellationToken));
|
||||
|
||||
// Public: published reviews only (the publish gate is enforced in the query) + the cached rating aggregate.
|
||||
[HttpGet("{nurseProfileId}/reviews")]
|
||||
[ProducesOkApiResponseType<NurseReviewsResult>]
|
||||
public async Task<IActionResult> Reviews(long nurseProfileId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default)
|
||||
=> OperationResult(await sender.Send(new ListReviewsForNurseQuery(nurseProfileId, page, pageSize), cancellationToken));
|
||||
|
||||
// Public: the per-nurse tag rollup ("% punctual", …) over published reviews.
|
||||
[HttpGet("{nurseProfileId}/review_tags")]
|
||||
[ProducesOkApiResponseType<NurseTagAggregatesResult>]
|
||||
public async Task<IActionResult> ReviewTags(long nurseProfileId, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new GetTagAggregatesQuery(nurseProfileId), cancellationToken));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord;
|
||||
using Baya.Application.Features.PatientCareRecords.Queries.GetPatientHistory;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>
|
||||
/// Patient-scoped clinical care records. Writing is nurse-only (with a confirmed booking for that patient);
|
||||
/// reading is restricted to the owning customer, a nurse with a confirmed booking, or admin — the strict
|
||||
/// clinical access rule is enforced in the handler (not just this route policy). Clinical bodies are encrypted
|
||||
/// at rest and decrypted only after the access check passes.
|
||||
/// </summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/patients")]
|
||||
[Authorize]
|
||||
[Display(Description = "Patient-scoped, encrypted clinical care records (write: nurse; read: owner/nurse/admin)")]
|
||||
public sealed class PatientCareRecordsController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpPost("{patientId}/care_records")]
|
||||
[ProducesOkApiResponseType<WriteCareRecordResult>]
|
||||
public async Task<IActionResult> Write(long patientId, WriteCareRecordBody body, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(
|
||||
new WritePatientCareRecordCommand(patientId, body.BookingId, body.Body), cancellationToken));
|
||||
|
||||
[HttpGet("{patientId}/care_records")]
|
||||
[ProducesOkApiResponseType<PagedResult<CareRecordDto>>]
|
||||
public async Task<IActionResult> History(long patientId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default)
|
||||
=> OperationResult(await sender.Send(new GetPatientHistoryQuery(patientId, page, pageSize), cancellationToken));
|
||||
|
||||
/// <summary>The care-record body (the patient id comes from the route).</summary>
|
||||
public record WriteCareRecordBody(long? BookingId, string Body);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Reviews.Commands.AttachReviewTags;
|
||||
using Baya.Application.Features.Reviews.Commands.ModerateReview;
|
||||
using Baya.Application.Models.Reviews;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Review write surface. Tagging a review is for its author (or a moderator, enforced in the handler); the
|
||||
/// moderation transition is admin/moderator-only (the narrower <see cref="ConstantPolicies.DynamicPermission"/>
|
||||
/// policy overrides the controller-level authorize).
|
||||
/// </summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/reviews")]
|
||||
[Authorize]
|
||||
[Display(Description = "Review tagging (owner/moderator) and moderation transitions (admin)")]
|
||||
public sealed class ReviewsController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpPost("{reviewId}/tags")]
|
||||
[ProducesOkApiResponseType<ReviewTagsResult>]
|
||||
public async Task<IActionResult> Tags(long reviewId, AttachReviewTagsBody body, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new AttachReviewTagsCommand(reviewId, body.TagCodes), cancellationToken));
|
||||
|
||||
[HttpPatch("{reviewId}/status")]
|
||||
[Authorize(ConstantPolicies.DynamicPermission)]
|
||||
[ProducesOkApiResponseType<ModerateReviewResult>]
|
||||
public async Task<IActionResult> Status(long reviewId, ModerateReviewBody body, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new ModerateReviewCommand(reviewId, body.Action, body.Reason), cancellationToken));
|
||||
|
||||
/// <summary>Tag-attach body (the review id comes from the route).</summary>
|
||||
public record AttachReviewTagsBody(IReadOnlyList<string> TagCodes);
|
||||
|
||||
/// <summary>Moderation body (the review id comes from the route): <c>publish</c>|<c>hide</c>|<c>reject</c>|<c>unpublish</c>.</summary>
|
||||
public record ModerateReviewBody(string Action, string? Reason);
|
||||
}
|
||||
@@ -18,4 +18,8 @@ public interface ICustomerProfileRepository
|
||||
/// <summary>The customer's <c>customer_profiles.id</c> from their user id — the tenancy anchor for
|
||||
/// patient operations. NULL when the user has no customer profile yet.</summary>
|
||||
Task<long?> GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The owning <c>users.id</c> for a customer profile — the notification recipient for a review
|
||||
/// outcome. NULL when the profile does not exist.</summary>
|
||||
Task<int?> GetUserIdByProfileIdAsync(long customerProfileId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>patient_care_records</c> store — patient-scoped clinical notes, encrypted at rest. Reads return the
|
||||
/// ciphertext; the handler decrypts only after the strict clinical access check passes. Access facts (patient
|
||||
/// ownership, a nurse's qualifying booking) are answered here so the handler can gate before touching the body.
|
||||
/// </summary>
|
||||
public interface IPatientCareRecordRepository
|
||||
{
|
||||
Task AddAsync(PatientCareRecord record, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The patient's owning <c>customer_profiles.id</c>, or null if the patient does not exist — the
|
||||
/// tenancy anchor for the owning-customer access branch.</summary>
|
||||
Task<long?> GetPatientOwnerCustomerIdAsync(long patientId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True if the nurse has a booking for the patient that reached a confirmed (or later) state — the
|
||||
/// gate for both writing and reading that patient's clinical history. A nurse never assigned is denied.</summary>
|
||||
Task<bool> NurseHasQualifyingBookingForPatientAsync(long nurseProfileId, long patientId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Patient-scoped longitudinal history, paginated, newest first — <b>ciphertext</b> bodies; the
|
||||
/// handler decrypts post-check.</summary>
|
||||
Task<PagedResult<CareRecordCipherRow>> GetPatientHistoryAsync(long patientId, int page, int pageSize, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>reviews</c> aggregate (reviews + tag links). Writes load tracked rows; reads project to DTOs and
|
||||
/// return <b>published</b> reviews only on any public path. The nurse rating aggregate is always derived from
|
||||
/// source (<c>AVG</c>/<c>COUNT</c> over currently-published reviews) — never an incremental delta.
|
||||
/// </summary>
|
||||
public interface IReviewRepository
|
||||
{
|
||||
Task AddAsync(Review review, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The booking's ownership + status + nurse, for the submit-eligibility guards. Null if absent.</summary>
|
||||
Task<ReviewableBooking?> GetReviewableBookingAsync(long bookingId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True if a (non-deleted) review already exists for the booking — the 1:1 pre-check.</summary>
|
||||
Task<bool> ExistsForBookingAsync(long bookingId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked review for a moderation transition. Null if absent.</summary>
|
||||
Task<Review?> GetTrackedAsync(long reviewId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked review with its tag links loaded — for the add/replace-tags command. Null if absent.</summary>
|
||||
Task<Review?> GetTrackedWithTagsAsync(long reviewId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Validates tag codes against the active master vocabulary; returns the matched <c>code → id</c>
|
||||
/// (an unknown/inactive code is simply absent from the map, so the handler can reject it cleanly).</summary>
|
||||
Task<IReadOnlyDictionary<string, long>> GetTagIdsByCodesAsync(IReadOnlyList<string> codes, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// The recompute-from-source stats for a nurse — <c>COUNT</c> and <c>SUM(rating)</c> over the nurse's
|
||||
/// currently <b>published</b> reviews <b>excluding</b> <paramref name="excludeReviewId"/>. The caller then
|
||||
/// folds in the transitioning review's <i>new</i> status in memory, so the aggregate is derived from source
|
||||
/// (not an incremental delta) and is correct <b>before</b> the single commit — a fresh query can't yet see
|
||||
/// the tracked, uncommitted status change. Pass <c>0</c> to exclude nothing (a brand-new review).
|
||||
/// </summary>
|
||||
Task<(int Count, long Sum)> GetPublishedRatingStatsExcludingAsync(long nurseProfileId, long excludeReviewId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The nurse's denormalized, published-only rating aggregate (kept correct by the recompute) —
|
||||
/// what the public reviews read returns and caches.</summary>
|
||||
Task<NurseReviewAggregateDto> GetNurseAggregateAsync(long nurseProfileId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Public, paginated list of a nurse's <b>published</b> reviews (with tag codes), newest first.</summary>
|
||||
Task<PagedResult<ReviewListItemDto>> ListPublishedForNurseAsync(long nurseProfileId, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Admin moderation queue, filtered by status (default caller-supplied), newest first, with any
|
||||
/// linked low-rating alert id joined in.</summary>
|
||||
Task<PagedResult<ModerationQueueItemDto>> GetModerationQueueAsync(string? status, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Per-nurse tag rollup over <b>published</b> reviews — each active tag's count and share.</summary>
|
||||
Task<NurseTagAggregatesResult> GetTagAggregatesAsync(long nurseProfileId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -23,6 +23,8 @@ public interface IUnitOfWork
|
||||
public IInvoiceRepository InvoiceRepository { get; }
|
||||
public IBnplRepository BnplRepository { get; }
|
||||
public IPayoutRepository PayoutRepository { get; }
|
||||
public IReviewRepository ReviewRepository { get; }
|
||||
public IPatientCareRecordRepository PatientCareRecordRepository { get; }
|
||||
Task CommitAsync();
|
||||
ValueTask RollBackAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Reviews;
|
||||
|
||||
/// <summary>The AI/automated pre-screen decision for a submitted review.</summary>
|
||||
public enum ModerationDecision
|
||||
{
|
||||
/// <summary>Clean text — safe to auto-publish (subject to the auto-approve config).</summary>
|
||||
Approve,
|
||||
|
||||
/// <summary>Suspicious — keep <c>pending_moderation</c> for a human to decide.</summary>
|
||||
Flag,
|
||||
|
||||
/// <summary>Clearly disallowed — a human can still override, but the pre-screen recommends rejecting.</summary>
|
||||
Reject
|
||||
}
|
||||
|
||||
/// <summary>The verdict a pre-screen returns: the decision plus a short machine reason.</summary>
|
||||
/// <param name="Decision">The recommended disposition.</param>
|
||||
/// <param name="Reason">A short reason code/text (e.g. <c>clean</c>, <c>banned_word:scam</c>).</param>
|
||||
public sealed record ModerationVerdict(ModerationDecision Decision, string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// Seam for automated review moderation (introduced backend-phase-14). The mock is a keyword filter /
|
||||
/// pass-through with no external call; a real text classifier / LLM endpoint swaps in by a registration
|
||||
/// change only — <c>ModerateReviewCommand</c> keeps decision authority and always allows a human override, so
|
||||
/// the real implementation never needs to touch the handler.
|
||||
/// </summary>
|
||||
public interface IReviewModerationService
|
||||
{
|
||||
ValueTask<ModerationVerdict> ScreenAsync(string? reviewText, CancellationToken cancellationToken = default);
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord;
|
||||
|
||||
/// <summary>
|
||||
/// A nurse writes a patient-scoped clinical note. Guards: the caller is a nurse, the patient exists, and the
|
||||
/// nurse has a <b>confirmed</b> (or later) booking for that patient — a nurse never assigned is denied. The
|
||||
/// clinical body is encrypted through <see cref="IFieldEncryptor"/> before it is persisted; plaintext never
|
||||
/// touches the column.
|
||||
/// </summary>
|
||||
internal sealed class WritePatientCareRecordCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IFieldEncryptor fieldEncryptor,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<WritePatientCareRecordCommand, OperationResult<WriteCareRecordResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<WriteCareRecordResult>> Handle(
|
||||
WritePatientCareRecordCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<WriteCareRecordResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var nurseProfileId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseProfileId is not { } nurseId)
|
||||
return OperationResult<WriteCareRecordResult>.ForbiddenResult("Only a nurse can write a care record.");
|
||||
|
||||
var owner = await unitOfWork.PatientCareRecordRepository.GetPatientOwnerCustomerIdAsync(request.PatientId, cancellationToken);
|
||||
if (owner is null)
|
||||
return OperationResult<WriteCareRecordResult>.NotFoundResult("Patient not found.");
|
||||
|
||||
var qualifies = await unitOfWork.PatientCareRecordRepository
|
||||
.NurseHasQualifyingBookingForPatientAsync(nurseId, request.PatientId, cancellationToken);
|
||||
if (!qualifies)
|
||||
return OperationResult<WriteCareRecordResult>.ForbiddenResult(
|
||||
"You can only write care records for a patient you have a confirmed booking with.");
|
||||
|
||||
var record = new PatientCareRecord
|
||||
{
|
||||
PatientId = request.PatientId,
|
||||
BookingId = request.BookingId,
|
||||
NurseProfileId = nurseId,
|
||||
BodyEncrypted = fieldEncryptor.Encrypt(request.Body.Trim()),
|
||||
RecordedAt = dateTimeProvider.UtcNow.UtcDateTime
|
||||
};
|
||||
|
||||
await unitOfWork.PatientCareRecordRepository.AddAsync(record, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<WriteCareRecordResult>.SuccessResult(
|
||||
new WriteCareRecordResult(record.Id, record.PatientId, record.RecordedAt));
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord;
|
||||
|
||||
public sealed class WritePatientCareRecordCommandValidator : AbstractValidator<WritePatientCareRecordCommand>
|
||||
{
|
||||
public WritePatientCareRecordCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PatientId).GreaterThan(0);
|
||||
RuleFor(x => x.BookingId).GreaterThan(0).When(x => x.BookingId.HasValue);
|
||||
RuleFor(x => x.Body).NotEmpty().MaximumLength(8000);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord;
|
||||
|
||||
/// <summary>A nurse authors a clinical note for a patient (optionally tagged with the booking that produced
|
||||
/// it). The patient id comes from the route; the body is encrypted at rest before persisting.</summary>
|
||||
public record WritePatientCareRecordCommand(long PatientId, long? BookingId, string Body)
|
||||
: IRequest<OperationResult<WriteCareRecordResult>>;
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.PatientCareRecords.Queries.GetPatientHistory;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a patient's longitudinal care history under the strict clinical access rule (enforced here, not just
|
||||
/// at the route): the owning customer, a nurse with a confirmed booking for the patient, or an admin — nobody
|
||||
/// else. Only after the check passes are the ciphertext bodies decrypted via <see cref="IFieldEncryptor"/>.
|
||||
/// </summary>
|
||||
internal sealed class GetPatientHistoryQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IFieldEncryptor fieldEncryptor)
|
||||
: IRequestHandler<GetPatientHistoryQuery, OperationResult<PagedResult<CareRecordDto>>>
|
||||
{
|
||||
private static readonly string[] AdminRoles = [RoleNames.Admin, RoleNames.SuperAdmin];
|
||||
|
||||
public async ValueTask<OperationResult<PagedResult<CareRecordDto>>> Handle(
|
||||
GetPatientHistoryQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PagedResult<CareRecordDto>>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var page = request.Page < 1 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
|
||||
var records = unitOfWork.PatientCareRecordRepository;
|
||||
|
||||
var ownerCustomerId = await records.GetPatientOwnerCustomerIdAsync(request.PatientId, cancellationToken);
|
||||
if (ownerCustomerId is null)
|
||||
return OperationResult<PagedResult<CareRecordDto>>.NotFoundResult("Patient not found.");
|
||||
|
||||
var allowed = currentUser.Roles.Any(AdminRoles.Contains);
|
||||
if (!allowed)
|
||||
{
|
||||
var customerProfileId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerProfileId == ownerCustomerId)
|
||||
{
|
||||
allowed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var nurseProfileId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseProfileId is { } nurseId)
|
||||
allowed = await records.NurseHasQualifyingBookingForPatientAsync(nurseId, request.PatientId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed)
|
||||
return OperationResult<PagedResult<CareRecordDto>>.ForbiddenResult(
|
||||
"You do not have clinical access to this patient's care records.");
|
||||
|
||||
var cipher = await records.GetPatientHistoryAsync(request.PatientId, page, pageSize, cancellationToken);
|
||||
|
||||
var items = cipher.Items
|
||||
.Select(r => new CareRecordDto(
|
||||
r.Id, r.PatientId, r.BookingId, r.NurseProfileId, r.NurseName,
|
||||
fieldEncryptor.Decrypt(r.BodyEncrypted), r.RecordedAt))
|
||||
.ToList();
|
||||
|
||||
return OperationResult<PagedResult<CareRecordDto>>.SuccessResult(
|
||||
new PagedResult<CareRecordDto>(items, cipher.Total, cipher.Page, cipher.PageSize));
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.PatientCareRecords.Queries.GetPatientHistory;
|
||||
|
||||
/// <summary>Patient-scoped longitudinal care history, paginated, newest first. Readable only by the owning
|
||||
/// customer, a nurse with a confirmed booking for the patient, or an admin.</summary>
|
||||
public record GetPatientHistoryQuery(long PatientId, int Page = 1, int PageSize = 20)
|
||||
: IRequest<OperationResult<PagedResult<CareRecordDto>>>;
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Commands.AttachReviewTags;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces a review's tag links with exactly the requested set. Authorized for the review's author or a
|
||||
/// moderator; a non-owner non-moderator is denied. Tag codes are validated against the active master
|
||||
/// vocabulary; the resulting set is de-duplicated so the <c>UNIQUE(review_id, review_tag_master_id)</c> is
|
||||
/// never violated.
|
||||
/// </summary>
|
||||
internal sealed class AttachReviewTagsCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<AttachReviewTagsCommand, OperationResult<ReviewTagsResult>>
|
||||
{
|
||||
private static readonly string[] ModeratorRoles = [RoleNames.Admin, RoleNames.SuperAdmin, RoleNames.Moderation];
|
||||
|
||||
public async ValueTask<OperationResult<ReviewTagsResult>> Handle(AttachReviewTagsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<ReviewTagsResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var review = await unitOfWork.ReviewRepository.GetTrackedWithTagsAsync(request.ReviewId, cancellationToken);
|
||||
if (review is null)
|
||||
return OperationResult<ReviewTagsResult>.NotFoundResult("Review not found.");
|
||||
|
||||
var isModerator = currentUser.Roles.Any(ModeratorRoles.Contains);
|
||||
if (!isModerator)
|
||||
{
|
||||
var customerProfileId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerProfileId != review.CustomerProfileId)
|
||||
return OperationResult<ReviewTagsResult>.ForbiddenResult("You can only tag your own review.");
|
||||
}
|
||||
|
||||
var distinct = request.TagCodes.Select(c => c.Trim()).Where(c => c.Length > 0).Distinct().ToList();
|
||||
var resolved = await unitOfWork.ReviewRepository.GetTagIdsByCodesAsync(distinct, cancellationToken);
|
||||
var unknown = distinct.Where(c => !resolved.ContainsKey(c)).ToList();
|
||||
if (unknown.Count > 0)
|
||||
return OperationResult<ReviewTagsResult>.FailureResult($"Unknown review tag(s): {string.Join(", ", unknown)}.");
|
||||
|
||||
// Replace: EF deletes the removed links and inserts the new ones in one transaction.
|
||||
review.TagLinks.Clear();
|
||||
foreach (var tagId in resolved.Values)
|
||||
review.TagLinks.Add(new ReviewTagLink { ReviewId = review.Id, ReviewTagMasterId = tagId });
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<ReviewTagsResult>.SuccessResult(new ReviewTagsResult(review.Id, distinct));
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Commands.AttachReviewTags;
|
||||
|
||||
public sealed class AttachReviewTagsCommandValidator : AbstractValidator<AttachReviewTagsCommand>
|
||||
{
|
||||
public AttachReviewTagsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ReviewId).GreaterThan(0);
|
||||
RuleFor(x => x.TagCodes).NotNull();
|
||||
RuleForEach(x => x.TagCodes).NotEmpty().MaximumLength(50);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Commands.AttachReviewTags;
|
||||
|
||||
/// <summary>Sets (replaces) the standardized tags on a review the caller owns (or a moderator manages). The
|
||||
/// resulting set is exactly <see cref="TagCodes"/>; the <c>UNIQUE(review_id, review_tag_master_id)</c> forbids
|
||||
/// a duplicate tag.</summary>
|
||||
public record AttachReviewTagsCommand(long ReviewId, IReadOnlyList<string> TagCodes)
|
||||
: IRequest<OperationResult<ReviewTagsResult>>;
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Commands.ModerateReview;
|
||||
|
||||
/// <summary>
|
||||
/// Applies a moderation transition and, in the <b>same transaction</b>: recomputes the nurse aggregate from
|
||||
/// source (§ <see cref="RecomputeNurseRating"/>) and stages the b7 search-index refresh. The transition is
|
||||
/// audited automatically because <see cref="Review"/> is <c>IAuditable</c> (the SaveChanges interceptor writes
|
||||
/// the diff). After commit the cached aggregate is invalidated and the author is notified of the outcome. This
|
||||
/// is the human decision authority — it can always override the AI pre-screen.
|
||||
/// </summary>
|
||||
internal sealed class ModerateReviewCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
ISearchIndexMaintainer searchIndex,
|
||||
ICacheService cache,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<ModerateReviewCommand, OperationResult<ModerateReviewResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<ModerateReviewResult>> Handle(ModerateReviewCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } moderatorId)
|
||||
return OperationResult<ModerateReviewResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var targetStatus = ReviewModerationAction.ToStatus(request.Action);
|
||||
if (targetStatus is null)
|
||||
return OperationResult<ModerateReviewResult>.FailureResult($"Unknown moderation action '{request.Action}'.");
|
||||
|
||||
var review = await unitOfWork.ReviewRepository.GetTrackedAsync(request.ReviewId, cancellationToken);
|
||||
if (review is null)
|
||||
return OperationResult<ModerateReviewResult>.NotFoundResult("Review not found.");
|
||||
|
||||
var reason = ReviewModerationAction.RequiresReason(request.Action) ? request.Reason : null;
|
||||
review.Moderate(targetStatus, reason, moderatorId, dateTimeProvider.UtcNow);
|
||||
|
||||
// From-source recompute + search refresh, staged on the same unit of work as the status change.
|
||||
await RecomputeNurseRating.ExecuteAsync(review, unitOfWork, searchIndex, cancellationToken);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
await ReviewCache.InvalidateAggregateAsync(cache, review.NurseProfileId, cancellationToken);
|
||||
|
||||
// Best-effort author notice of the outcome (in-app). Not on the critical path.
|
||||
var recipientUserId = await unitOfWork.CustomerProfileRepository.GetUserIdByProfileIdAsync(review.CustomerProfileId, cancellationToken);
|
||||
if (recipientUserId is { } uid)
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(uid, "review_moderated", "Your review was updated",
|
||||
$"Your review is now {review.ModerationStatus}.",
|
||||
$"{{\"reviewId\":{review.Id},\"status\":\"{review.ModerationStatus}\"}}"),
|
||||
cancellationToken);
|
||||
|
||||
var aggregate = await unitOfWork.ReviewRepository.GetNurseAggregateAsync(review.NurseProfileId, cancellationToken);
|
||||
return OperationResult<ModerateReviewResult>.SuccessResult(
|
||||
new ModerateReviewResult(review.Id, review.ModerationStatus, aggregate.AverageRating, aggregate.PublishedCount));
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Commands.ModerateReview;
|
||||
|
||||
public sealed class ModerateReviewCommandValidator : AbstractValidator<ModerateReviewCommand>
|
||||
{
|
||||
public ModerateReviewCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ReviewId).GreaterThan(0);
|
||||
RuleFor(x => x.Action)
|
||||
.NotEmpty()
|
||||
.Must(ReviewModerationAction.All.Contains)
|
||||
.WithMessage($"Action must be one of: {string.Join(", ", ReviewModerationAction.All)}.");
|
||||
|
||||
// Hide and reject must carry a reason for the audit trail and the author notice.
|
||||
RuleFor(x => x.Reason)
|
||||
.NotEmpty()
|
||||
.MaximumLength(500)
|
||||
.When(x => x.Action is not null && ReviewModerationAction.RequiresReason(x.Action));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Commands.ModerateReview;
|
||||
|
||||
/// <summary>An admin/moderator transitions a review: <c>publish</c> | <c>hide</c> | <c>reject</c> |
|
||||
/// <c>unpublish</c>. Every transition recomputes the nurse aggregate from source and refreshes the search
|
||||
/// index in the same transaction. Hide/reject require a reason.</summary>
|
||||
public record ModerateReviewCommand(long ReviewId, string Action, string? Reason)
|
||||
: IRequest<OperationResult<ModerateReviewResult>>;
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Reviews;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Contracts.SupportAlerts;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using Baya.Domain.Entities.SupportAlerts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Commands.SubmitReview;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the one allowed review for a completed booking. Guards (all clean <see cref="OperationResult"/>
|
||||
/// failures, never throws): the caller owns the booking (tenancy), the booking is completed/closed, and no
|
||||
/// review exists yet (the 1:1 rule, with the UNIQUE index as the race backstop). The AI pre-screen sets the
|
||||
/// initial disposition — clean text stays <c>pending_moderation</c> by default (the publish gate); the human
|
||||
/// path can always override later. A rating at/below the configured threshold raises a low-rating support
|
||||
/// alert reliably (after commit, not swallowed).
|
||||
/// </summary>
|
||||
internal sealed class SubmitReviewCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IPlatformConfig platformConfig,
|
||||
IReviewModerationService moderation,
|
||||
ISupportAlertService supportAlerts,
|
||||
ISearchIndexMaintainer searchIndex,
|
||||
ICacheService cache,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<SubmitReviewCommand, OperationResult<SubmitReviewResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<SubmitReviewResult>> Handle(SubmitReviewCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<SubmitReviewResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var customerProfileId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerProfileId is not { } customerId)
|
||||
return OperationResult<SubmitReviewResult>.ForbiddenResult("Only a customer can review a booking.");
|
||||
|
||||
var booking = await unitOfWork.ReviewRepository.GetReviewableBookingAsync(request.BookingId, cancellationToken);
|
||||
if (booking is null)
|
||||
return OperationResult<SubmitReviewResult>.NotFoundResult("Booking not found.");
|
||||
|
||||
// Tenancy: a customer can only review their own booking — a mismatch is a not-found, never a leak.
|
||||
if (booking.CustomerProfileId != customerId)
|
||||
return OperationResult<SubmitReviewResult>.NotFoundResult("Booking not found.");
|
||||
|
||||
// Eligibility: only a completed/closed booking is reviewable (never cancelled/expired/in-progress).
|
||||
if (booking.Status is not (BookingStatus.Completed or BookingStatus.Closed))
|
||||
return OperationResult<SubmitReviewResult>.FailureResult("A review can only be left for a completed booking.");
|
||||
|
||||
if (await unitOfWork.ReviewRepository.ExistsForBookingAsync(request.BookingId, cancellationToken))
|
||||
return OperationResult<SubmitReviewResult>.ConflictResult("This booking has already been reviewed.");
|
||||
|
||||
var tagMasterIds = new List<long>();
|
||||
if (request.TagCodes is { Count: > 0 } codes)
|
||||
{
|
||||
var distinct = codes.Select(c => c.Trim()).Where(c => c.Length > 0).Distinct().ToList();
|
||||
var resolved = await unitOfWork.ReviewRepository.GetTagIdsByCodesAsync(distinct, cancellationToken);
|
||||
var unknown = distinct.Where(c => !resolved.ContainsKey(c)).ToList();
|
||||
if (unknown.Count > 0)
|
||||
return OperationResult<SubmitReviewResult>.FailureResult($"Unknown review tag(s): {string.Join(", ", unknown)}.");
|
||||
tagMasterIds.AddRange(resolved.Values);
|
||||
}
|
||||
|
||||
// AI pre-screen sets the initial disposition; the mock defaults clean text to a human-review flag so the
|
||||
// publish gate holds. Decision authority still rests with ModerateReviewCommand (human override).
|
||||
var verdict = await moderation.ScreenAsync(request.Body, cancellationToken);
|
||||
var initialStatus = verdict.Decision switch
|
||||
{
|
||||
ModerationDecision.Approve => ReviewModerationStatus.Published,
|
||||
ModerationDecision.Reject => ReviewModerationStatus.Hidden,
|
||||
_ => ReviewModerationStatus.PendingModeration
|
||||
};
|
||||
|
||||
var now = dateTimeProvider.UtcNow;
|
||||
|
||||
var review = new Review
|
||||
{
|
||||
BookingId = booking.BookingId,
|
||||
CustomerProfileId = customerId,
|
||||
NurseProfileId = booking.NurseProfileId,
|
||||
Rating = request.Rating,
|
||||
Body = string.IsNullOrWhiteSpace(request.Body) ? null : request.Body.Trim()
|
||||
};
|
||||
foreach (var tagId in tagMasterIds)
|
||||
review.TagLinks.Add(new ReviewTagLink { ReviewTagMasterId = tagId });
|
||||
|
||||
if (initialStatus != ReviewModerationStatus.PendingModeration)
|
||||
review.Moderate(initialStatus, initialStatus == ReviewModerationStatus.Hidden ? verdict.Reason : null, null, now);
|
||||
|
||||
await unitOfWork.ReviewRepository.AddAsync(review, cancellationToken);
|
||||
|
||||
// A brand-new pending review does not count; an auto-published/auto-hidden one recomputes the aggregate
|
||||
// from source in the same transaction (id 0 excludes nothing — the new review is folded in by status).
|
||||
if (initialStatus != ReviewModerationStatus.PendingModeration)
|
||||
await RecomputeNurseRating.ExecuteAsync(review, unitOfWork, searchIndex, cancellationToken);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
if (initialStatus != ReviewModerationStatus.PendingModeration)
|
||||
await ReviewCache.InvalidateAggregateAsync(cache, booking.NurseProfileId, cancellationToken);
|
||||
|
||||
// Low-rating safety signal: raise the internal alert reliably (self-committing facade, after the main
|
||||
// commit) — never a silently-swallowed best-effort. A raise failure surfaces to the caller.
|
||||
var lowRatingAlertRaised = false;
|
||||
var threshold = await platformConfig.GetConfig<decimal>("min_rating_for_support_alert", cancellationToken);
|
||||
if (request.Rating <= threshold)
|
||||
{
|
||||
await supportAlerts.RaiseAsync(
|
||||
SupportAlertType.LowRating, "review", review.Id.ToString(), SupportAlertSeverity.High,
|
||||
bookingId: booking.BookingId, reviewId: review.Id, cancellationToken);
|
||||
lowRatingAlertRaised = true;
|
||||
}
|
||||
|
||||
return OperationResult<SubmitReviewResult>.SuccessResult(
|
||||
new SubmitReviewResult(review.Id, review.ModerationStatus, lowRatingAlertRaised));
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Commands.SubmitReview;
|
||||
|
||||
public sealed class SubmitReviewCommandValidator : AbstractValidator<SubmitReviewCommand>
|
||||
{
|
||||
public SubmitReviewCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.BookingId).GreaterThan(0);
|
||||
RuleFor(x => x.Rating).InclusiveBetween(1, 5);
|
||||
RuleFor(x => x.Body).MaximumLength(2000);
|
||||
RuleForEach(x => x.TagCodes).NotEmpty().MaximumLength(50).When(x => x.TagCodes is not null);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Commands.SubmitReview;
|
||||
|
||||
/// <summary>A customer leaves the one allowed review for a completed booking: a 1–5 rating, optional free-text
|
||||
/// body, and optional standardized tag codes. The booking id comes from the route, not the body.</summary>
|
||||
public record SubmitReviewCommand(
|
||||
long BookingId,
|
||||
int Rating,
|
||||
string? Body,
|
||||
IReadOnlyList<string>? TagCodes) : IRequest<OperationResult<SubmitReviewResult>>;
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Queries.GetReviewModerationQueue;
|
||||
|
||||
internal sealed class GetReviewModerationQueueQueryHandler(IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetReviewModerationQueueQuery, OperationResult<PagedResult<ModerationQueueItemDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<ModerationQueueItemDto>>> Handle(
|
||||
GetReviewModerationQueueQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var page = request.Page < 1 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
|
||||
|
||||
var status = string.IsNullOrWhiteSpace(request.Status)
|
||||
? ReviewModerationStatus.PendingModeration
|
||||
: request.Status;
|
||||
|
||||
if (!ReviewModerationStatus.IsValid(status))
|
||||
return OperationResult<PagedResult<ModerationQueueItemDto>>.FailureResult(
|
||||
$"Unknown moderation status '{status}'.");
|
||||
|
||||
var result = await unitOfWork.ReviewRepository.GetModerationQueueAsync(status, page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<ModerationQueueItemDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Queries.GetReviewModerationQueue;
|
||||
|
||||
/// <summary>Admin moderation queue, paginated and filterable by <c>moderation_status</c> (defaults to
|
||||
/// <c>pending_moderation</c>). Includes any linked low-rating alert id for staff triage.</summary>
|
||||
public record GetReviewModerationQueueQuery(string? Status = null, int Page = 1, int PageSize = 20)
|
||||
: IRequest<OperationResult<PagedResult<ModerationQueueItemDto>>>;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Queries.GetTagAggregates;
|
||||
|
||||
internal sealed class GetTagAggregatesQueryHandler(IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetTagAggregatesQuery, OperationResult<NurseTagAggregatesResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<NurseTagAggregatesResult>> Handle(
|
||||
GetTagAggregatesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await unitOfWork.ReviewRepository.GetTagAggregatesAsync(request.NurseProfileId, cancellationToken);
|
||||
return OperationResult<NurseTagAggregatesResult>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Queries.GetTagAggregates;
|
||||
|
||||
/// <summary>Public per-nurse tag rollup ("% punctual", …) computed over the nurse's <b>published</b> reviews.</summary>
|
||||
public record GetTagAggregatesQuery(long NurseProfileId) : IRequest<OperationResult<NurseTagAggregatesResult>>;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Queries.ListReviewsForNurse;
|
||||
|
||||
/// <summary>
|
||||
/// Public reviews read. Returns <b>published</b> reviews only (the publish gate is enforced at the query
|
||||
/// layer, not the UI) and the nurse's rating aggregate — read from the denormalized, published-only columns
|
||||
/// and cached, with cache invalidation on every moderation transition.
|
||||
/// </summary>
|
||||
internal sealed class ListReviewsForNurseQueryHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
ICacheService cache)
|
||||
: IRequestHandler<ListReviewsForNurseQuery, OperationResult<NurseReviewsResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<NurseReviewsResult>> Handle(ListReviewsForNurseQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var page = request.Page < 1 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize;
|
||||
|
||||
var aggregate = await cache.GetOrCreateAsync(
|
||||
ReviewCache.AggregateKey(request.NurseProfileId),
|
||||
async ct => await unitOfWork.ReviewRepository.GetNurseAggregateAsync(request.NurseProfileId, ct),
|
||||
ReviewCache.Ttl,
|
||||
cancellationToken);
|
||||
|
||||
var reviews = await unitOfWork.ReviewRepository.ListPublishedForNurseAsync(request.NurseProfileId, page, pageSize, cancellationToken);
|
||||
|
||||
return OperationResult<NurseReviewsResult>.SuccessResult(new NurseReviewsResult(aggregate, reviews));
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Reviews.Queries.ListReviewsForNurse;
|
||||
|
||||
/// <summary>Public, paginated list of a nurse's <b>published</b> reviews plus the cached rating aggregate.</summary>
|
||||
public record ListReviewsForNurseQuery(long NurseProfileId, int Page = 1, int PageSize = 20)
|
||||
: IRequest<OperationResult<NurseReviewsResult>>;
|
||||
@@ -0,0 +1,48 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
|
||||
namespace Baya.Application.Features.Reviews;
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes a nurse's denormalized rating aggregate <b>from source</b> — never by an incremental
|
||||
/// <c>+delta</c>/<c>-delta</c>. It reads <c>COUNT</c>/<c>SUM(rating)</c> over the nurse's currently
|
||||
/// <b>published</b> reviews (excluding the transitioning review), then folds in that review's <i>new</i>
|
||||
/// moderation status. This is the fix for inflated-rating-after-hide drift: hiding a 1-star lowers the count
|
||||
/// and re-derives the average from what remains public.
|
||||
/// <para>
|
||||
/// It mutates the tracked <see cref="Baya.Domain.Entities.Identity.NurseProfile"/> and <b>stages</b> the b7
|
||||
/// search-index refresh on the same unit of work; the caller's single <c>CommitAsync</c> persists the review
|
||||
/// transition, the aggregate, and the projection atomically. It does not commit. Invoked by <b>every</b>
|
||||
/// moderation transition and by an auto-published/auto-hidden submit.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class RecomputeNurseRating
|
||||
{
|
||||
public static async Task ExecuteAsync(
|
||||
Review changedReview,
|
||||
IUnitOfWork unitOfWork,
|
||||
ISearchIndexMaintainer searchIndex,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(changedReview.NurseProfileId, cancellationToken);
|
||||
if (profile is null)
|
||||
return;
|
||||
|
||||
var (count, sum) = await unitOfWork.ReviewRepository
|
||||
.GetPublishedRatingStatsExcludingAsync(changedReview.NurseProfileId, changedReview.Id, cancellationToken);
|
||||
|
||||
if (changedReview.ModerationStatus == ReviewModerationStatus.Published)
|
||||
{
|
||||
count += 1;
|
||||
sum += changedReview.Rating;
|
||||
}
|
||||
|
||||
var average = count > 0 ? Math.Round(sum / (decimal)count, 2, MidpointRounding.AwayFromZero) : 0m;
|
||||
profile.SetReviewAggregates(average, count);
|
||||
|
||||
// Any aggregate change must reach the search projection in the same transaction (staged inline).
|
||||
await searchIndex.ReindexNurseAsync(profile, null, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
|
||||
namespace Baya.Application.Features.Reviews;
|
||||
|
||||
/// <summary>Cache-key scheme for the public per-nurse rating aggregate. The aggregate read is cached; every
|
||||
/// moderation transition that can change it invalidates the nurse's key so a stale, inflated average is never
|
||||
/// served.</summary>
|
||||
internal static class ReviewCache
|
||||
{
|
||||
private static readonly TimeSpan AggregateTtl = TimeSpan.FromMinutes(10);
|
||||
|
||||
public static string AggregateKey(long nurseProfileId) => $"nurse_review_agg:{nurseProfileId}";
|
||||
|
||||
public static TimeSpan Ttl => AggregateTtl;
|
||||
|
||||
public static ValueTask InvalidateAggregateAsync(ICacheService cache, long nurseProfileId, CancellationToken cancellationToken)
|
||||
=> cache.RemoveAsync(AggregateKey(nurseProfileId), cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Reviews;
|
||||
|
||||
/// <summary>A patient-care-record row as it leaves the repository — the clinical body is still <b>ciphertext</b>
|
||||
/// here; the handler decrypts it only after the strict clinical access check passes, then maps to
|
||||
/// <see cref="CareRecordDto"/>. Keeping the cipher in the projection means no query path can surface plaintext.</summary>
|
||||
public record CareRecordCipherRow(
|
||||
long Id,
|
||||
long PatientId,
|
||||
long? BookingId,
|
||||
long NurseProfileId,
|
||||
string? NurseName,
|
||||
string BodyEncrypted,
|
||||
DateTime RecordedAt);
|
||||
|
||||
/// <summary>A decrypted clinical note returned to an authorized reader (owning customer / nurse with a
|
||||
/// confirmed booking / admin). Newest first.</summary>
|
||||
public record CareRecordDto(
|
||||
long Id,
|
||||
long PatientId,
|
||||
long? BookingId,
|
||||
long NurseProfileId,
|
||||
string? NurseName,
|
||||
string Body,
|
||||
DateTime RecordedAt);
|
||||
|
||||
/// <summary>What <c>WritePatientCareRecordCommand</c> returns.</summary>
|
||||
public record WriteCareRecordResult(long Id, long PatientId, DateTime RecordedAt);
|
||||
@@ -0,0 +1,54 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
|
||||
namespace Baya.Application.Models.Reviews;
|
||||
|
||||
/// <summary>The minimal booking facts a review submission needs — resolved from the <c>bookings</c> row to
|
||||
/// enforce ownership, the completed/closed eligibility gate, and the nurse the aggregate belongs to.</summary>
|
||||
public record ReviewableBooking(long BookingId, long CustomerProfileId, long NurseProfileId, string Status);
|
||||
|
||||
/// <summary>What <c>SubmitReviewCommand</c> returns — the new review id and its (always
|
||||
/// <c>pending_moderation</c>) status, plus whether a low-rating support alert was raised.</summary>
|
||||
public record SubmitReviewResult(long Id, string ModerationStatus, bool LowRatingAlertRaised);
|
||||
|
||||
/// <summary>What <c>ModerateReviewCommand</c> returns — the new status and the recomputed nurse aggregate
|
||||
/// (so a caller/test can see the from-source recompute immediately). Rating is a decimal average.</summary>
|
||||
public record ModerateReviewResult(long Id, string ModerationStatus, decimal AverageRating, int TotalReviews);
|
||||
|
||||
/// <summary>The result of attaching/replacing a review's tags — the review id and its resulting tag codes.</summary>
|
||||
public record ReviewTagsResult(long ReviewId, IReadOnlyList<string> TagCodes);
|
||||
|
||||
/// <summary>A public, published review line item — never carries moderation internals.</summary>
|
||||
public record ReviewListItemDto(
|
||||
long Id,
|
||||
int Rating,
|
||||
string? Body,
|
||||
IReadOnlyList<string> TagCodes,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>The public nurse rating aggregate — derived from <b>published</b> reviews only.</summary>
|
||||
public record NurseReviewAggregateDto(decimal AverageRating, int PublishedCount);
|
||||
|
||||
/// <summary>The public reviews payload for a nurse — the aggregate plus a page of published reviews.</summary>
|
||||
public record NurseReviewsResult(NurseReviewAggregateDto Aggregate, PagedResult<ReviewListItemDto> Reviews);
|
||||
|
||||
/// <summary>An admin moderation-queue row — the full review plus any linked low-rating alert id (internal use
|
||||
/// only; support alerts never appear on a user-facing route).</summary>
|
||||
public record ModerationQueueItemDto(
|
||||
long Id,
|
||||
long BookingId,
|
||||
long NurseProfileId,
|
||||
long CustomerProfileId,
|
||||
int Rating,
|
||||
string? Body,
|
||||
string ModerationStatus,
|
||||
string? ModerationReason,
|
||||
long? LowRatingAlertId,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>One tag's rollup for a nurse — the count of published reviews carrying it and that as a percentage
|
||||
/// of the nurse's published reviews.</summary>
|
||||
public record TagAggregateDto(string Code, string LabelFa, string LabelEn, int Count, decimal Percentage);
|
||||
|
||||
/// <summary>The per-nurse tag rollup — the published-review base and each tag's share.</summary>
|
||||
public record NurseTagAggregatesResult(int PublishedReviewCount, IReadOnlyList<TagAggregateDto> Tags);
|
||||
@@ -51,4 +51,13 @@ public class NurseProfile : BaseEntity<long>
|
||||
public void MarkUnverified() => IsVerified = false;
|
||||
|
||||
public void SetAcceptingBookings(bool accepting) => IsAcceptingBookings = accepting;
|
||||
|
||||
/// <summary>The single sanctioned write path for the denormalized rating aggregates — called only by the
|
||||
/// b14 reviews phase, which recomputes both values from source (published reviews only) on every
|
||||
/// moderation transition. Never accepted from a request.</summary>
|
||||
public void SetReviewAggregates(decimal averageRating, int totalReviews)
|
||||
{
|
||||
AverageRating = averageRating;
|
||||
TotalReviews = totalReviews;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Reviews;
|
||||
|
||||
/// <summary>
|
||||
/// A nurse-authored clinical note that accumulates into a <b>patient-scoped</b> longitudinal care history —
|
||||
/// the scoping key is <see cref="PatientId"/>, <b>not</b> a booking. When a different nurse takes over they
|
||||
/// read the prior history before accepting, so notes must never be siloed per visit; <see cref="BookingId"/>
|
||||
/// is nullable provenance only (which visit produced the note).
|
||||
/// <para>
|
||||
/// The clinical body is <b>encrypted at rest</b>: <see cref="BodyEncrypted"/> holds the
|
||||
/// <c>IFieldEncryptor</c>-produced ciphertext (never plaintext). There is deliberately no EF value converter
|
||||
/// on this column — the handler encrypts on write and decrypts only after the strict clinical access check
|
||||
/// passes on read, so no query can accidentally surface plaintext.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class PatientCareRecord : BaseEntity<long>
|
||||
{
|
||||
/// <summary>The scoping key — the patient this note belongs to (tenancy is the patient's owning customer).</summary>
|
||||
public long PatientId { get; set; }
|
||||
|
||||
/// <summary>Provenance only: the visit that produced the note. Nullable — a note is not booking-scoped.</summary>
|
||||
public long? BookingId { get; set; }
|
||||
|
||||
/// <summary>The authoring nurse's <c>nurse_profiles.id</c>.</summary>
|
||||
public long NurseProfileId { get; set; }
|
||||
|
||||
/// <summary>The clinical note, encrypted at rest via <c>IFieldEncryptor</c>. Never plaintext, never logged.</summary>
|
||||
public string BodyEncrypted { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>When the note was recorded (UTC). Stored as <c>datetime2</c> so the history read can order by it
|
||||
/// on both SQL Server and the SQLite test provider (which cannot translate <c>DateTimeOffset</c> ordering).</summary>
|
||||
public DateTime RecordedAt { get; set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Reviews;
|
||||
|
||||
/// <summary>
|
||||
/// One customer review of a <b>completed</b> booking (1:1 with the booking, enforced by a UNIQUE on
|
||||
/// <see cref="BookingId"/>). A review is public social-proof and enters <see cref="ReviewModerationStatus.PendingModeration"/>;
|
||||
/// it is never rendered publicly and never counted in the nurse aggregate until a moderation transition
|
||||
/// publishes it. The moderation fields are guarded (private setters) and mutated only through
|
||||
/// <see cref="Moderate"/>, so every transition also stamps the moderator and time. Marked
|
||||
/// <see cref="IAuditable"/> so the SaveChanges interceptor writes an append-only <c>audit_logs</c> diff for
|
||||
/// creation and every moderation transition in the same transaction.
|
||||
/// </summary>
|
||||
public class Review : BaseEntity<long>, IAuditable
|
||||
{
|
||||
/// <summary>1:1 with the completed booking (UNIQUE) — the anti-fraud, one-review-per-booking backstop.</summary>
|
||||
public long BookingId { get; set; }
|
||||
|
||||
/// <summary>The reviewing customer's <c>customer_profiles.id</c> (author).</summary>
|
||||
public long CustomerProfileId { get; set; }
|
||||
|
||||
/// <summary>The reviewed nurse's <c>nurse_profiles.id</c> — the aggregate this review drives when published.</summary>
|
||||
public long NurseProfileId { get; set; }
|
||||
|
||||
/// <summary>1–5, enforced by a DB CHECK and by validation.</summary>
|
||||
public int Rating { get; set; }
|
||||
|
||||
/// <summary>Optional free-text body.</summary>
|
||||
public string? Body { get; set; }
|
||||
|
||||
/// <summary>Guarded — mutated only through <see cref="Moderate"/>. Defaults to
|
||||
/// <see cref="ReviewModerationStatus.PendingModeration"/>; never public until published.</summary>
|
||||
public string ModerationStatus { get; private set; } = Reviews.ReviewModerationStatus.PendingModeration;
|
||||
|
||||
/// <summary>Set on hide/reject.</summary>
|
||||
public string? ModerationReason { get; private set; }
|
||||
|
||||
public int? ModeratedById { get; private set; }
|
||||
|
||||
public DateTimeOffset? ModeratedAt { get; private set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public ICollection<ReviewTagLink> TagLinks { get; set; } = new List<ReviewTagLink>();
|
||||
|
||||
/// <summary>Applies a moderation transition and stamps the moderator + time. The caller maps the action
|
||||
/// to a valid target status; a hide/reject carries a reason (cleared on publish/unpublish). The moderator
|
||||
/// is null when the AI pre-screen set the initial disposition on submit.</summary>
|
||||
public void Moderate(string targetStatus, string? reason, int? moderatedById, DateTimeOffset now)
|
||||
{
|
||||
ModerationStatus = targetStatus;
|
||||
ModerationReason = reason;
|
||||
ModeratedById = moderatedById;
|
||||
ModeratedAt = now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace Baya.Domain.Entities.Reviews;
|
||||
|
||||
/// <summary>
|
||||
/// The moderation lifecycle of a <see cref="Review"/>, persisted as these stable snake_case codes (never a
|
||||
/// C# enum member name). A review is born <see cref="PendingModeration"/> and is <b>never public</b> until an
|
||||
/// admin/AI transition moves it to <see cref="Published"/>. Only <see cref="Published"/> reviews are rendered
|
||||
/// publicly and only <see cref="Published"/> reviews count toward the nurse aggregate — which is recomputed
|
||||
/// from source on <b>every</b> transition so hiding a low rating never leaves a stale, inflated average.
|
||||
/// </summary>
|
||||
public static class ReviewModerationStatus
|
||||
{
|
||||
/// <summary>Default on submit. Not public, not counted in the aggregate.</summary>
|
||||
public const string PendingModeration = "pending_moderation";
|
||||
|
||||
/// <summary>Approved and publicly visible. The only status counted in the nurse aggregate.</summary>
|
||||
public const string Published = "published";
|
||||
|
||||
/// <summary>Withheld from the public list (e.g. off-topic, abusive) — removed from the aggregate.</summary>
|
||||
public const string Hidden = "hidden";
|
||||
|
||||
/// <summary>Rejected outright (e.g. fake/spam) — never public, not counted.</summary>
|
||||
public const string Rejected = "rejected";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = [PendingModeration, Published, Hidden, Rejected];
|
||||
|
||||
public static bool IsValid(string status) => All.Contains(status);
|
||||
}
|
||||
|
||||
/// <summary>The moderator action codes accepted by the moderation endpoint, mapped to a target status.</summary>
|
||||
public static class ReviewModerationAction
|
||||
{
|
||||
public const string Publish = "publish";
|
||||
public const string Hide = "hide";
|
||||
public const string Reject = "reject";
|
||||
|
||||
/// <summary>Pull a published review back to <see cref="ReviewModerationStatus.PendingModeration"/> — it
|
||||
/// leaves the public list and the aggregate is recomputed downward.</summary>
|
||||
public const string Unpublish = "unpublish";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = [Publish, Hide, Reject, Unpublish];
|
||||
|
||||
/// <summary>Maps an action to its resulting <see cref="ReviewModerationStatus"/>, or null if unknown.</summary>
|
||||
public static string? ToStatus(string action) => action switch
|
||||
{
|
||||
Publish => ReviewModerationStatus.Published,
|
||||
Hide => ReviewModerationStatus.Hidden,
|
||||
Reject => ReviewModerationStatus.Rejected,
|
||||
Unpublish => ReviewModerationStatus.PendingModeration,
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>Hide and reject must carry a reason; publish/unpublish need none.</summary>
|
||||
public static bool RequiresReason(string action) => action is Hide or Reject;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Reviews;
|
||||
|
||||
/// <summary>
|
||||
/// The N:N join between a <see cref="Review"/> and a <see cref="ReviewTagMaster"/>. A <c>UNIQUE(review_id,
|
||||
/// review_tag_master_id)</c> forbids the same tag twice on one review.
|
||||
/// </summary>
|
||||
public class ReviewTagLink : BaseEntity<long>
|
||||
{
|
||||
public long ReviewId { get; set; }
|
||||
public Review Review { get; set; }
|
||||
|
||||
public long ReviewTagMasterId { get; set; }
|
||||
public ReviewTagMaster Tag { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Baya.Domain.Common;
|
||||
|
||||
namespace Baya.Domain.Entities.Reviews;
|
||||
|
||||
/// <summary>
|
||||
/// The standardized review-tag vocabulary (e.g. <c>punctual</c>, <c>professional</c>) used to turn qualitative
|
||||
/// feedback into quantitative rollups ("% punctual"). Reference data — seeded via <c>HasData</c>, toggled with
|
||||
/// <see cref="IsActive"/>, ordered by <see cref="SortOrder"/>. Growing the vocabulary is an admin/seed insert,
|
||||
/// not a schema change.
|
||||
/// </summary>
|
||||
public class ReviewTagMaster : BaseEntity<long>
|
||||
{
|
||||
/// <summary>Stable machine code (UNIQUE), e.g. <c>punctual</c>.</summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
public string LabelFa { get; set; } = string.Empty;
|
||||
public string LabelEn { get; set; } = string.Empty;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public ICollection<ReviewTagLink> Links { get; set; } = new List<ReviewTagLink>();
|
||||
}
|
||||
|
||||
/// <summary>The starter tag vocabulary codes seeded with the migration.</summary>
|
||||
public static class ReviewTagCodes
|
||||
{
|
||||
public const string Punctual = "punctual";
|
||||
public const string Professional = "professional";
|
||||
public const string Clean = "clean";
|
||||
public const string Kind = "kind";
|
||||
public const string Communicative = "communicative";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = [Punctual, Professional, Clean, Kind, Communicative];
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Reviews;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic mock <see cref="IReviewModerationService"/> (b14) — a keyword filter / pass-through with no
|
||||
/// external call. A banned-word hit → <see cref="ModerationDecision.Reject"/>; otherwise clean text → a
|
||||
/// human-review <see cref="ModerationDecision.Flag"/> by default (so the publish gate holds), or
|
||||
/// <see cref="ModerationDecision.Approve"/> when <see cref="ReviewModerationOptions.AutoApproveClean"/> is set.
|
||||
/// The real text classifier / LLM endpoint swaps in by a registration change only — the moderation command
|
||||
/// keeps decision authority and the human override, so it never touches the handler.
|
||||
/// </summary>
|
||||
public sealed class MockReviewModerationService(IOptions<SeamOptions> options) : IReviewModerationService
|
||||
{
|
||||
private readonly ReviewModerationOptions _options = options.Value.ReviewModeration;
|
||||
|
||||
public ValueTask<ModerationVerdict> ScreenAsync(string? reviewText, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var text = reviewText?.Trim() ?? string.Empty;
|
||||
|
||||
var hit = _options.BannedWords.FirstOrDefault(
|
||||
w => !string.IsNullOrWhiteSpace(w) && text.Contains(w, StringComparison.OrdinalIgnoreCase));
|
||||
if (hit is not null)
|
||||
return ValueTask.FromResult(new ModerationVerdict(ModerationDecision.Reject, $"banned_word:{hit}"));
|
||||
|
||||
var decision = _options.AutoApproveClean ? ModerationDecision.Approve : ModerationDecision.Flag;
|
||||
return ValueTask.FromResult(new ModerationVerdict(decision, "clean"));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,22 @@ public sealed class SeamOptions
|
||||
public BnplOptions Bnpl { get; set; } = new();
|
||||
public CurrencyOptions Currency { get; set; } = new();
|
||||
public BankTransferOptions BankTransfer { get; set; } = new();
|
||||
public ReviewModerationOptions ReviewModeration { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tunes the mock <c>IReviewModerationService</c> (b14 AI review pre-screen). By default clean text returns a
|
||||
/// human-review <c>Flag</c> (keeping the publish gate on); a banned-word hit returns <c>Reject</c>. Set
|
||||
/// <see cref="AutoApproveClean"/> to have clean text auto-<c>Approve</c> (auto-publish). The real text
|
||||
/// classifier / LLM endpoint ignores these knobs.
|
||||
/// </summary>
|
||||
public sealed class ReviewModerationOptions
|
||||
{
|
||||
/// <summary>When true, clean text is auto-approved (auto-published) instead of flagged for human review.</summary>
|
||||
public bool AutoApproveClean { get; set; }
|
||||
|
||||
/// <summary>Case-insensitive substrings that mark a review for rejection.</summary>
|
||||
public List<string> BannedWords { get; set; } = ["scam", "fraud", "کلاهبردار"];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+7
@@ -1,6 +1,7 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Invoices;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Reviews;
|
||||
using Baya.Infrastructure.CrossCutting.Seams;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -80,6 +81,12 @@ public static class ServiceCollectionExtension
|
||||
// the payout status machine + the nurse_payout_booking_links UNIQUE remain the irreversible-transfer backstop.
|
||||
services.AddSingleton<IBankTransferProvider, MockBankTransferProvider>();
|
||||
|
||||
// AI review moderation (backend-phase-14). The mock is a keyword filter / pass-through (clean → human
|
||||
// flag by default so the publish gate holds; banned word → reject; config toggle auto-approves clean).
|
||||
// A real text classifier / LLM endpoint swaps in by a registration change only — ModerateReviewCommand
|
||||
// keeps decision authority + the human override, so the real impl never touches the handler.
|
||||
services.AddSingleton<IReviewModerationService, MockReviewModerationService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.ReviewsConfig;
|
||||
|
||||
/// <summary>
|
||||
/// <c>patient_care_records</c> — nurse-authored, encrypted, <b>patient-scoped</b> clinical notes. The
|
||||
/// <c>(patient_id, recorded_at DESC)</c> index serves the longitudinal history read. <c>booking_id</c> is
|
||||
/// nullable provenance only (which visit produced the note) — the scoping key is <c>patient_id</c>.
|
||||
/// <c>body_encrypted</c> stores <c>IFieldEncryptor</c> ciphertext (no EF value converter — the handler
|
||||
/// encrypts/decrypts explicitly, so no query path can surface plaintext).
|
||||
/// </summary>
|
||||
internal sealed class PatientCareRecordConfig : IEntityTypeConfiguration<PatientCareRecord>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PatientCareRecord> builder)
|
||||
{
|
||||
builder.ToTable("PatientCareRecords", "reviews");
|
||||
|
||||
builder.Property(r => r.BodyEncrypted).IsRequired();
|
||||
builder.Property(r => r.RecordedAt).IsRequired();
|
||||
|
||||
builder.HasIndex(r => new { r.PatientId, r.RecordedAt })
|
||||
.HasDatabaseName("IX_PatientCareRecords_Patient_RecordedAt");
|
||||
|
||||
builder.HasOne<Patient>().WithMany().HasForeignKey(r => r.PatientId).IsRequired();
|
||||
builder.HasOne<NurseProfile>().WithMany().HasForeignKey(r => r.NurseProfileId).IsRequired();
|
||||
builder.HasOne<Booking>().WithMany().HasForeignKey(r => r.BookingId);
|
||||
|
||||
builder.HasQueryFilter(r => r.DeletedAt == null);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.ReviewsConfig;
|
||||
|
||||
/// <summary>
|
||||
/// <c>reviews</c> — one review per completed booking. The <c>UNIQUE(booking_id)</c> is the authoritative 1:1
|
||||
/// backstop; <c>CHECK(rating BETWEEN 1 AND 5)</c> guards the score. Only <c>published</c> reviews are public
|
||||
/// or counted in the nurse aggregate — the <c>(nurse_profile_id, moderation_status)</c> index serves both the
|
||||
/// public list and the recompute; the <c>moderation_status</c> index serves the moderation queue.
|
||||
/// </summary>
|
||||
internal sealed class ReviewConfig : IEntityTypeConfiguration<Review>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Review> builder)
|
||||
{
|
||||
builder.ToTable("Reviews", "reviews", t => t.HasCheckConstraint(
|
||||
"CK_Reviews_Rating", "[Rating] BETWEEN 1 AND 5"));
|
||||
|
||||
builder.Property(r => r.Rating).IsRequired();
|
||||
builder.Property(r => r.Body).HasMaxLength(2000);
|
||||
builder.Property(r => r.ModerationStatus).HasMaxLength(30).IsRequired();
|
||||
builder.Property(r => r.ModerationReason).HasMaxLength(500);
|
||||
|
||||
builder.HasIndex(r => r.BookingId).IsUnique();
|
||||
builder.HasIndex(r => new { r.NurseProfileId, r.ModerationStatus });
|
||||
builder.HasIndex(r => r.ModerationStatus);
|
||||
|
||||
builder.HasOne<Booking>().WithMany().HasForeignKey(r => r.BookingId).IsRequired();
|
||||
builder.HasOne<CustomerProfile>().WithMany().HasForeignKey(r => r.CustomerProfileId).IsRequired();
|
||||
builder.HasOne<NurseProfile>().WithMany().HasForeignKey(r => r.NurseProfileId).IsRequired();
|
||||
|
||||
builder.HasMany(r => r.TagLinks).WithOne(l => l.Review).HasForeignKey(l => l.ReviewId).IsRequired();
|
||||
|
||||
builder.HasQueryFilter(r => r.DeletedAt == null);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.ReviewsConfig;
|
||||
|
||||
/// <summary>
|
||||
/// <c>review_tag_links</c> — the N:N join between a review and a master tag. The
|
||||
/// <c>UNIQUE(review_id, review_tag_master_id)</c> forbids the same tag twice on one review; its leading column
|
||||
/// is <c>review_id</c>, so it also serves "load a review's tags".
|
||||
/// </summary>
|
||||
internal sealed class ReviewTagLinkConfig : IEntityTypeConfiguration<ReviewTagLink>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ReviewTagLink> builder)
|
||||
{
|
||||
builder.ToTable("ReviewTagLinks", "reviews");
|
||||
|
||||
builder.HasIndex(l => new { l.ReviewId, l.ReviewTagMasterId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_ReviewTagLinks_Review_Tag");
|
||||
|
||||
builder.HasOne(l => l.Review).WithMany(r => r.TagLinks).HasForeignKey(l => l.ReviewId).IsRequired();
|
||||
builder.HasOne(l => l.Tag).WithMany(t => t.Links).HasForeignKey(l => l.ReviewTagMasterId).IsRequired();
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.ReviewsConfig;
|
||||
|
||||
/// <summary>
|
||||
/// <c>review_tags_master</c> — the standardized tag vocabulary (seeded via <c>HasData</c>). <c>code</c> is
|
||||
/// UNIQUE; ordering/toggling is data (<c>sort_order</c>/<c>is_active</c>).
|
||||
/// </summary>
|
||||
internal sealed class ReviewTagMasterConfig : IEntityTypeConfiguration<ReviewTagMaster>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ReviewTagMaster> builder)
|
||||
{
|
||||
builder.ToTable("ReviewTagsMaster", "reviews");
|
||||
|
||||
builder.Property(t => t.Code).HasMaxLength(50).IsRequired();
|
||||
builder.Property(t => t.LabelFa).HasMaxLength(100).IsRequired();
|
||||
builder.Property(t => t.LabelEn).HasMaxLength(100).IsRequired();
|
||||
builder.Property(t => t.IsActive).HasDefaultValue(true);
|
||||
builder.Property(t => t.SortOrder).HasDefaultValue(0);
|
||||
|
||||
builder.HasIndex(t => t.Code).IsUnique();
|
||||
builder.HasIndex(t => new { t.IsActive, t.SortOrder });
|
||||
|
||||
builder.HasData(ReviewsSeed.Tags());
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.ReviewsConfig;
|
||||
|
||||
/// <summary>
|
||||
/// The starter review-tag vocabulary, seeded via <c>HasData</c> so it lands with the migration on a fresh DB.
|
||||
/// Ids are fixed and deterministic (1…5, sort_order = id) so re-running is idempotent and the model snapshot
|
||||
/// stays stable. Growing the vocabulary is another seeded/admin row, not a schema change.
|
||||
/// </summary>
|
||||
internal static class ReviewsSeed
|
||||
{
|
||||
// (id, code, label_fa, label_en)
|
||||
private static readonly (long Id, string Code, string LabelFa, string LabelEn)[] TagRows =
|
||||
[
|
||||
(1, ReviewTagCodes.Punctual, "وقتشناس", "Punctual"),
|
||||
(2, ReviewTagCodes.Professional, "حرفهای", "Professional"),
|
||||
(3, ReviewTagCodes.Clean, "تمیز و بهداشتی", "Clean"),
|
||||
(4, ReviewTagCodes.Kind, "مهربان", "Kind"),
|
||||
(5, ReviewTagCodes.Communicative, "خوشبرخورد", "Communicative"),
|
||||
];
|
||||
|
||||
public static object[] Tags()
|
||||
{
|
||||
var ts = SeedConstants.Timestamp;
|
||||
return TagRows
|
||||
.Select(t => (object)new
|
||||
{
|
||||
t.Id,
|
||||
t.Code,
|
||||
t.LabelFa,
|
||||
t.LabelEn,
|
||||
IsActive = true,
|
||||
SortOrder = (int)t.Id,
|
||||
CreatedAt = ts
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
+5595
File diff suppressed because it is too large
Load Diff
+269
@@ -0,0 +1,269 @@
|
||||
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 ReviewsAndPatientCareRecords : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "reviews");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PatientCareRecords",
|
||||
schema: "reviews",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
PatientId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BookingId = table.Column<long>(type: "bigint", nullable: true),
|
||||
NurseProfileId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BodyEncrypted = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
RecordedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
DeletedAt = 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_PatientCareRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PatientCareRecords_Bookings_BookingId",
|
||||
column: x => x.BookingId,
|
||||
principalSchema: "booking",
|
||||
principalTable: "Bookings",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_PatientCareRecords_NurseProfiles_NurseProfileId",
|
||||
column: x => x.NurseProfileId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "NurseProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_PatientCareRecords_Patients_PatientId",
|
||||
column: x => x.PatientId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "Patients",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Reviews",
|
||||
schema: "reviews",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
BookingId = table.Column<long>(type: "bigint", nullable: false),
|
||||
CustomerProfileId = table.Column<long>(type: "bigint", nullable: false),
|
||||
NurseProfileId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Rating = table.Column<int>(type: "int", nullable: false),
|
||||
Body = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
ModerationStatus = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
|
||||
ModerationReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
ModeratedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModeratedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
DeletedAt = 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_Reviews", x => x.Id);
|
||||
table.CheckConstraint("CK_Reviews_Rating", "[Rating] BETWEEN 1 AND 5");
|
||||
table.ForeignKey(
|
||||
name: "FK_Reviews_Bookings_BookingId",
|
||||
column: x => x.BookingId,
|
||||
principalSchema: "booking",
|
||||
principalTable: "Bookings",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Reviews_CustomerProfiles_CustomerProfileId",
|
||||
column: x => x.CustomerProfileId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "CustomerProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Reviews_NurseProfiles_NurseProfileId",
|
||||
column: x => x.NurseProfileId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "NurseProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ReviewTagsMasters",
|
||||
schema: "reviews",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
LabelFa = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
LabelEn = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||
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_ReviewTagsMasters", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ReviewTagLinks",
|
||||
schema: "reviews",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ReviewId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ReviewTagMasterId = table.Column<long>(type: "bigint", 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_ReviewTagLinks", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ReviewTagLinks_ReviewTagsMasters_ReviewTagMasterId",
|
||||
column: x => x.ReviewTagMasterId,
|
||||
principalSchema: "reviews",
|
||||
principalTable: "ReviewTagsMasters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ReviewTagLinks_Reviews_ReviewId",
|
||||
column: x => x.ReviewId,
|
||||
principalSchema: "reviews",
|
||||
principalTable: "Reviews",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
schema: "reviews",
|
||||
table: "ReviewTagsMasters",
|
||||
columns: new[] { "Id", "Code", "CreatedAt", "CreatedById", "IsActive", "LabelEn", "LabelFa", "ModifiedAt", "ModifiedById", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1L, "punctual", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, true, "Punctual", "وقتشناس", null, null, 1 },
|
||||
{ 2L, "professional", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, true, "Professional", "حرفهای", null, null, 2 },
|
||||
{ 3L, "clean", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, true, "Clean", "تمیز و بهداشتی", null, null, 3 },
|
||||
{ 4L, "kind", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, true, "Kind", "مهربان", null, null, 4 },
|
||||
{ 5L, "communicative", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, true, "Communicative", "خوشبرخورد", null, null, 5 }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PatientCareRecords_BookingId",
|
||||
schema: "reviews",
|
||||
table: "PatientCareRecords",
|
||||
column: "BookingId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PatientCareRecords_NurseProfileId",
|
||||
schema: "reviews",
|
||||
table: "PatientCareRecords",
|
||||
column: "NurseProfileId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PatientCareRecords_Patient_RecordedAt",
|
||||
schema: "reviews",
|
||||
table: "PatientCareRecords",
|
||||
columns: new[] { "PatientId", "RecordedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Reviews_BookingId",
|
||||
schema: "reviews",
|
||||
table: "Reviews",
|
||||
column: "BookingId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Reviews_CustomerProfileId",
|
||||
schema: "reviews",
|
||||
table: "Reviews",
|
||||
column: "CustomerProfileId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Reviews_ModerationStatus",
|
||||
schema: "reviews",
|
||||
table: "Reviews",
|
||||
column: "ModerationStatus");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Reviews_NurseProfileId_ModerationStatus",
|
||||
schema: "reviews",
|
||||
table: "Reviews",
|
||||
columns: new[] { "NurseProfileId", "ModerationStatus" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReviewTagLinks_ReviewTagMasterId",
|
||||
schema: "reviews",
|
||||
table: "ReviewTagLinks",
|
||||
column: "ReviewTagMasterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ReviewTagLinks_Review_Tag",
|
||||
schema: "reviews",
|
||||
table: "ReviewTagLinks",
|
||||
columns: new[] { "ReviewId", "ReviewTagMasterId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReviewTagsMasters_Code",
|
||||
schema: "reviews",
|
||||
table: "ReviewTagsMasters",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ReviewTagsMasters_IsActive_SortOrder",
|
||||
schema: "reviews",
|
||||
table: "ReviewTagsMasters",
|
||||
columns: new[] { "IsActive", "SortOrder" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PatientCareRecords",
|
||||
schema: "reviews");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ReviewTagLinks",
|
||||
schema: "reviews");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ReviewTagsMasters",
|
||||
schema: "reviews");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Reviews",
|
||||
schema: "reviews");
|
||||
}
|
||||
}
|
||||
}
|
||||
+335
@@ -3586,6 +3586,272 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Reviews.PatientCareRecord", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("BodyEncrypted")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<long?>("BookingId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("NurseProfileId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("PatientId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("RecordedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BookingId");
|
||||
|
||||
b.HasIndex("NurseProfileId");
|
||||
|
||||
b.HasIndex("PatientId", "RecordedAt")
|
||||
.HasDatabaseName("IX_PatientCareRecords_Patient_RecordedAt");
|
||||
|
||||
b.ToTable("PatientCareRecords", "reviews");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Reviews.Review", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Body")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<long>("BookingId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("CustomerProfileId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModeratedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModeratedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ModerationReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("ModerationStatus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("nvarchar(30)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("NurseProfileId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("Rating")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BookingId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("CustomerProfileId");
|
||||
|
||||
b.HasIndex("ModerationStatus");
|
||||
|
||||
b.HasIndex("NurseProfileId", "ModerationStatus");
|
||||
|
||||
b.ToTable("Reviews", "reviews", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Reviews_Rating", "[Rating] BETWEEN 1 AND 5");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Reviews.ReviewTagLink", 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<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("ReviewId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("ReviewTagMasterId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ReviewTagMasterId");
|
||||
|
||||
b.HasIndex("ReviewId", "ReviewTagMasterId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_ReviewTagLinks_Review_Tag");
|
||||
|
||||
b.ToTable("ReviewTagLinks", "reviews");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Reviews.ReviewTagMaster", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("LabelEn")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("LabelFa")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("IsActive", "SortOrder");
|
||||
|
||||
b.ToTable("ReviewTagsMasters", "reviews");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1L,
|
||||
Code = "punctual",
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
IsActive = true,
|
||||
LabelEn = "Punctual",
|
||||
LabelFa = "وقتشناس",
|
||||
SortOrder = 1
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2L,
|
||||
Code = "professional",
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
IsActive = true,
|
||||
LabelEn = "Professional",
|
||||
LabelFa = "حرفهای",
|
||||
SortOrder = 2
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3L,
|
||||
Code = "clean",
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
IsActive = true,
|
||||
LabelEn = "Clean",
|
||||
LabelFa = "تمیز و بهداشتی",
|
||||
SortOrder = 3
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 4L,
|
||||
Code = "kind",
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
IsActive = true,
|
||||
LabelEn = "Kind",
|
||||
LabelFa = "مهربان",
|
||||
SortOrder = 4
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 5L,
|
||||
Code = "communicative",
|
||||
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||
IsActive = true,
|
||||
LabelEn = "Communicative",
|
||||
LabelFa = "خوشبرخورد",
|
||||
SortOrder = 5
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -4979,6 +5245,65 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Reviews.PatientCareRecord", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BookingId");
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("NurseProfileId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Identity.Patient", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Reviews.Review", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Booking.Booking", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BookingId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomerProfileId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("NurseProfileId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Reviews.ReviewTagLink", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Reviews.Review", "Review")
|
||||
.WithMany("TagLinks")
|
||||
.HasForeignKey("ReviewId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Reviews.ReviewTagMaster", "Tag")
|
||||
.WithMany("Links")
|
||||
.HasForeignKey("ReviewTagMasterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Review");
|
||||
|
||||
b.Navigation("Tag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
|
||||
@@ -5215,6 +5540,16 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Payouts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Reviews.Review", b =>
|
||||
{
|
||||
b.Navigation("TagLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Reviews.ReviewTagMaster", b =>
|
||||
{
|
||||
b.Navigation("Links");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.User.Role", b =>
|
||||
{
|
||||
b.Navigation("Claims");
|
||||
|
||||
+4
@@ -27,6 +27,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IInvoiceRepository InvoiceRepository { get; }
|
||||
public IBnplRepository BnplRepository { get; }
|
||||
public IPayoutRepository PayoutRepository { get; }
|
||||
public IReviewRepository ReviewRepository { get; }
|
||||
public IPatientCareRecordRepository PatientCareRecordRepository { get; }
|
||||
|
||||
public UnitOfWork(ApplicationDbContext db)
|
||||
{
|
||||
@@ -52,6 +54,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
InvoiceRepository = new InvoiceRepository(_db);
|
||||
BnplRepository = new BnplRepository(_db);
|
||||
PayoutRepository = new PayoutRepository(_db);
|
||||
ReviewRepository = new ReviewRepository(_db);
|
||||
PatientCareRecordRepository = new PatientCareRecordRepository(_db);
|
||||
}
|
||||
|
||||
public Task CommitAsync()
|
||||
|
||||
+6
@@ -29,4 +29,10 @@ internal sealed class CustomerProfileRepository : BaseAsyncRepository<CustomerPr
|
||||
.Where(c => c.UserId == userId)
|
||||
.Select(c => (long?)c.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public Task<int?> GetUserIdByProfileIdAsync(long customerProfileId, CancellationToken cancellationToken)
|
||||
=> TableNoTracking
|
||||
.Where(c => c.Id == customerProfileId)
|
||||
.Select(c => (int?)c.UserId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Repositories;
|
||||
|
||||
internal sealed class PatientCareRecordRepository : BaseAsyncRepository<PatientCareRecord>, IPatientCareRecordRepository
|
||||
{
|
||||
// A booking that reached (at least) confirmed ties a nurse to a patient — the clinical-access gate. A
|
||||
// pending_payment or cancelled booking never grants clinical access.
|
||||
private static readonly string[] QualifyingBookingStatuses =
|
||||
[
|
||||
BookingStatus.Confirmed, BookingStatus.InProgress, BookingStatus.Completed,
|
||||
BookingStatus.Disputed, BookingStatus.Closed
|
||||
];
|
||||
|
||||
public PatientCareRecordRepository(ApplicationDbContext dbContext) : base(dbContext)
|
||||
{
|
||||
}
|
||||
|
||||
public Task AddAsync(PatientCareRecord record, CancellationToken cancellationToken) => base.AddAsync(record);
|
||||
|
||||
public Task<long?> GetPatientOwnerCustomerIdAsync(long patientId, CancellationToken cancellationToken)
|
||||
=> DbContext.Set<Patient>().AsNoTracking()
|
||||
.Where(p => p.Id == patientId)
|
||||
.Select(p => (long?)p.CustomerId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public Task<bool> NurseHasQualifyingBookingForPatientAsync(long nurseProfileId, long patientId, CancellationToken cancellationToken)
|
||||
=> DbContext.Set<Booking>().AsNoTracking()
|
||||
.AnyAsync(b => b.NurseId == nurseProfileId
|
||||
&& b.PatientId == patientId
|
||||
&& QualifyingBookingStatuses.Contains(b.Status), cancellationToken);
|
||||
|
||||
public async Task<PagedResult<CareRecordCipherRow>> GetPatientHistoryAsync(
|
||||
long patientId, int page, int pageSize, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = Entities.AsNoTracking().Where(r => r.PatientId == patientId);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var rows = await query
|
||||
.OrderByDescending(r => r.RecordedAt)
|
||||
.ThenByDescending(r => r.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id, r.PatientId, r.BookingId, r.NurseProfileId, r.BodyEncrypted, r.RecordedAt,
|
||||
NurseName = DbContext.Set<NurseProfile>()
|
||||
.Where(n => n.Id == r.NurseProfileId)
|
||||
.Select(n => n.User.Name + " " + n.User.FamilyName)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var items = rows
|
||||
.Select(r => new CareRecordCipherRow(
|
||||
r.Id, r.PatientId, r.BookingId, r.NurseProfileId,
|
||||
string.IsNullOrWhiteSpace(r.NurseName) ? null : r.NurseName.Trim(),
|
||||
r.BodyEncrypted, r.RecordedAt))
|
||||
.ToList();
|
||||
|
||||
return new PagedResult<CareRecordCipherRow>(items, total, page, pageSize);
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Reviews;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using Baya.Domain.Entities.SupportAlerts;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Repositories;
|
||||
|
||||
internal sealed class ReviewRepository : BaseAsyncRepository<Review>, IReviewRepository
|
||||
{
|
||||
public ReviewRepository(ApplicationDbContext dbContext) : base(dbContext)
|
||||
{
|
||||
}
|
||||
|
||||
public Task AddAsync(Review review, CancellationToken cancellationToken) => base.AddAsync(review);
|
||||
|
||||
public Task<ReviewableBooking?> GetReviewableBookingAsync(long bookingId, CancellationToken cancellationToken)
|
||||
=> DbContext.Set<Booking>().AsNoTracking()
|
||||
.Where(b => b.Id == bookingId)
|
||||
.Select(b => new ReviewableBooking(b.Id, b.CustomerId, b.NurseId, b.Status))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public Task<bool> ExistsForBookingAsync(long bookingId, CancellationToken cancellationToken)
|
||||
=> Entities.AnyAsync(r => r.BookingId == bookingId, cancellationToken);
|
||||
|
||||
public Task<Review?> GetTrackedAsync(long reviewId, CancellationToken cancellationToken)
|
||||
=> Entities.FirstOrDefaultAsync(r => r.Id == reviewId, cancellationToken);
|
||||
|
||||
public Task<Review?> GetTrackedWithTagsAsync(long reviewId, CancellationToken cancellationToken)
|
||||
=> Entities.Include(r => r.TagLinks).FirstOrDefaultAsync(r => r.Id == reviewId, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, long>> GetTagIdsByCodesAsync(IReadOnlyList<string> codes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (codes.Count == 0)
|
||||
return new Dictionary<string, long>();
|
||||
|
||||
return await DbContext.Set<ReviewTagMaster>().AsNoTracking()
|
||||
.Where(t => t.IsActive && codes.Contains(t.Code))
|
||||
.ToDictionaryAsync(t => t.Code, t => t.Id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<(int Count, long Sum)> GetPublishedRatingStatsExcludingAsync(
|
||||
long nurseProfileId, long excludeReviewId, CancellationToken cancellationToken)
|
||||
{
|
||||
var stats = await Entities.AsNoTracking()
|
||||
.Where(r => r.NurseProfileId == nurseProfileId
|
||||
&& r.ModerationStatus == ReviewModerationStatus.Published
|
||||
&& r.Id != excludeReviewId)
|
||||
.GroupBy(_ => 1)
|
||||
.Select(g => new { Count = g.Count(), Sum = g.Sum(x => (long)x.Rating) })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return stats is null ? (0, 0) : (stats.Count, stats.Sum);
|
||||
}
|
||||
|
||||
public async Task<NurseReviewAggregateDto> GetNurseAggregateAsync(long nurseProfileId, CancellationToken cancellationToken)
|
||||
{
|
||||
var aggregate = await DbContext.Set<NurseProfile>().AsNoTracking()
|
||||
.Where(p => p.Id == nurseProfileId)
|
||||
.Select(p => new NurseReviewAggregateDto(p.AverageRating, p.TotalReviews))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return aggregate ?? new NurseReviewAggregateDto(0m, 0);
|
||||
}
|
||||
|
||||
public async Task<PagedResult<ReviewListItemDto>> ListPublishedForNurseAsync(
|
||||
long nurseProfileId, int page, int pageSize, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = Entities.AsNoTracking()
|
||||
.Where(r => r.NurseProfileId == nurseProfileId && r.ModerationStatus == ReviewModerationStatus.Published);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(r => r.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(r => new ReviewListItemDto(
|
||||
r.Id, r.Rating, r.Body,
|
||||
r.TagLinks.Select(l => l.Tag.Code).ToList(),
|
||||
r.CreatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new PagedResult<ReviewListItemDto>(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<PagedResult<ModerationQueueItemDto>> GetModerationQueueAsync(
|
||||
string? status, int page, int pageSize, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = Entities.AsNoTracking().AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
query = query.Where(r => r.ModerationStatus == status);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(r => r.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(r => new ModerationQueueItemDto(
|
||||
r.Id, r.BookingId, r.NurseProfileId, r.CustomerProfileId, r.Rating, r.Body,
|
||||
r.ModerationStatus, r.ModerationReason,
|
||||
DbContext.Set<SupportAlert>()
|
||||
.Where(a => a.ReviewId == r.Id && a.Type == SupportAlertType.LowRating)
|
||||
.Select(a => (long?)a.Id)
|
||||
.FirstOrDefault(),
|
||||
r.CreatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new PagedResult<ModerationQueueItemDto>(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<NurseTagAggregatesResult> GetTagAggregatesAsync(long nurseProfileId, CancellationToken cancellationToken)
|
||||
{
|
||||
var publishedCount = await Entities.AsNoTracking()
|
||||
.CountAsync(r => r.NurseProfileId == nurseProfileId && r.ModerationStatus == ReviewModerationStatus.Published, cancellationToken);
|
||||
|
||||
var tagCounts = await DbContext.Set<ReviewTagLink>().AsNoTracking()
|
||||
.Where(l => l.Review.NurseProfileId == nurseProfileId && l.Review.ModerationStatus == ReviewModerationStatus.Published)
|
||||
.GroupBy(l => l.ReviewTagMasterId)
|
||||
.Select(g => new { TagId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.TagId, x => x.Count, cancellationToken);
|
||||
|
||||
var masters = await DbContext.Set<ReviewTagMaster>().AsNoTracking()
|
||||
.Where(t => t.IsActive)
|
||||
.OrderBy(t => t.SortOrder)
|
||||
.Select(t => new { t.Id, t.Code, t.LabelFa, t.LabelEn })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var tags = masters.Select(m =>
|
||||
{
|
||||
var count = tagCounts.GetValueOrDefault(m.Id);
|
||||
var percentage = publishedCount > 0
|
||||
? Math.Round(100m * count / publishedCount, 1, MidpointRounding.AwayFromZero)
|
||||
: 0m;
|
||||
return new TagAggregateDto(m.Code, m.LabelFa, m.LabelEn, count, percentage);
|
||||
}).ToList();
|
||||
|
||||
return new NurseTagAggregatesResult(publishedCount, tags);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Net;
|
||||
|
||||
namespace Baya.Test.Api;
|
||||
|
||||
/// <summary>HTTP-pipeline coverage for the b14 review + care-record surface: public reads are anonymous, admin
|
||||
/// and patient reads are auth-gated (401 without a token).</summary>
|
||||
public class ReviewsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublicReviews_UnknownNurse_Returns200WithEmptyAggregate()
|
||||
{
|
||||
var anon = factory.CreateClient();
|
||||
var response = await anon.GetAsync("/api/v1/nurses/999999/reviews");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var data = await AuthTestClient.ReadDataAsync(response);
|
||||
Assert.Equal(0, data.GetProperty("aggregate").GetProperty("publishedCount").GetInt32());
|
||||
Assert.Empty(data.GetProperty("reviews").GetProperty("items").EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicReviewTags_UnknownNurse_Returns200()
|
||||
{
|
||||
var anon = factory.CreateClient();
|
||||
var response = await anon.GetAsync("/api/v1/nurses/999999/review_tags");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
// The seeded active tag vocabulary is always returned (with zero counts for an unknown nurse).
|
||||
var data = await AuthTestClient.ReadDataAsync(response);
|
||||
Assert.NotEmpty(data.GetProperty("tags").EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ModerationQueue_Unauthenticated_Returns401()
|
||||
{
|
||||
var anon = factory.CreateClient();
|
||||
var response = await anon.GetAsync("/api/v1/admin/reviews/moderation_queue");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatientCareRecords_Unauthenticated_Returns401()
|
||||
{
|
||||
var anon = factory.CreateClient();
|
||||
var response = await anon.GetAsync("/api/v1/patients/1/care_records");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord;
|
||||
using Baya.Application.Features.PatientCareRecords.Queries.GetPatientHistory;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using Baya.Tests.Setup.Setups;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Test.Foundation.Reviews;
|
||||
|
||||
public class PatientCareRecordHandlerTests
|
||||
{
|
||||
private const string Note = "Blood pressure 120/80, patient calm, meds taken.";
|
||||
|
||||
[Fact]
|
||||
public async Task Nurse_with_confirmed_booking_writes_encrypted_record_and_can_read_it_back()
|
||||
{
|
||||
using var host = new ReviewsTestHost();
|
||||
host.SeedBooking(BookingStatus.Confirmed);
|
||||
|
||||
var write = new WritePatientCareRecordCommandHandler(
|
||||
host.AsNurse(), host.UnitOfWork, TestFieldEncryptor.Instance, host.Clock());
|
||||
var written = await write.Handle(new WritePatientCareRecordCommand(host.PatientId, null, Note), CancellationToken.None);
|
||||
Assert.True(written.IsSuccess);
|
||||
|
||||
// Stored column is ciphertext, not plaintext.
|
||||
var stored = host.Db.Set<PatientCareRecord>().AsNoTracking().Single().BodyEncrypted;
|
||||
Assert.NotEqual(Note, stored);
|
||||
Assert.Equal(Note, TestFieldEncryptor.Instance.Decrypt(stored));
|
||||
|
||||
// The authoring nurse can read the decrypted history.
|
||||
var read = await new GetPatientHistoryQueryHandler(host.AsNurse(), host.UnitOfWork, TestFieldEncryptor.Instance)
|
||||
.Handle(new GetPatientHistoryQuery(host.PatientId), CancellationToken.None);
|
||||
Assert.True(read.IsSuccess);
|
||||
var record = Assert.Single(read.Result.Items);
|
||||
Assert.Equal(Note, record.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owning_customer_can_read_history()
|
||||
{
|
||||
using var host = new ReviewsTestHost();
|
||||
host.SeedBooking(BookingStatus.Completed);
|
||||
await new WritePatientCareRecordCommandHandler(host.AsNurse(), host.UnitOfWork, TestFieldEncryptor.Instance, host.Clock())
|
||||
.Handle(new WritePatientCareRecordCommand(host.PatientId, null, Note), CancellationToken.None);
|
||||
|
||||
var read = await new GetPatientHistoryQueryHandler(host.AsCustomer(), host.UnitOfWork, TestFieldEncryptor.Instance)
|
||||
.Handle(new GetPatientHistoryQuery(host.PatientId), CancellationToken.None);
|
||||
|
||||
Assert.True(read.IsSuccess);
|
||||
Assert.Equal(Note, Assert.Single(read.Result.Items).Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nurse_without_a_booking_is_denied_write_and_read()
|
||||
{
|
||||
using var host = new ReviewsTestHost();
|
||||
host.SeedBooking(BookingStatus.Completed); // for the assigned nurse only
|
||||
await new WritePatientCareRecordCommandHandler(host.AsNurse(), host.UnitOfWork, TestFieldEncryptor.Instance, host.Clock())
|
||||
.Handle(new WritePatientCareRecordCommand(host.PatientId, null, Note), CancellationToken.None);
|
||||
|
||||
var write = await new WritePatientCareRecordCommandHandler(host.AsOtherNurse(), host.UnitOfWork, TestFieldEncryptor.Instance, host.Clock())
|
||||
.Handle(new WritePatientCareRecordCommand(host.PatientId, null, "unauthorized"), CancellationToken.None);
|
||||
Assert.False(write.IsSuccess);
|
||||
Assert.True(write.IsForbidden);
|
||||
|
||||
var read = await new GetPatientHistoryQueryHandler(host.AsOtherNurse(), host.UnitOfWork, TestFieldEncryptor.Instance)
|
||||
.Handle(new GetPatientHistoryQuery(host.PatientId), CancellationToken.None);
|
||||
Assert.False(read.IsSuccess);
|
||||
Assert.True(read.IsForbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_can_read_history()
|
||||
{
|
||||
using var host = new ReviewsTestHost();
|
||||
host.SeedBooking(BookingStatus.Completed);
|
||||
await new WritePatientCareRecordCommandHandler(host.AsNurse(), host.UnitOfWork, TestFieldEncryptor.Instance, host.Clock())
|
||||
.Handle(new WritePatientCareRecordCommand(host.PatientId, null, Note), CancellationToken.None);
|
||||
|
||||
var read = await new GetPatientHistoryQueryHandler(host.AsAdmin(), host.UnitOfWork, TestFieldEncryptor.Instance)
|
||||
.Handle(new GetPatientHistoryQuery(host.PatientId), CancellationToken.None);
|
||||
|
||||
Assert.True(read.IsSuccess);
|
||||
Assert.Single(read.Result.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_for_missing_patient_is_not_found()
|
||||
{
|
||||
using var host = new ReviewsTestHost();
|
||||
var write = await new WritePatientCareRecordCommandHandler(host.AsNurse(), host.UnitOfWork, TestFieldEncryptor.Instance, host.Clock())
|
||||
.Handle(new WritePatientCareRecordCommand(999999, null, Note), CancellationToken.None);
|
||||
|
||||
Assert.False(write.IsSuccess);
|
||||
Assert.True(write.IsNotFound);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Reviews;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Contracts.SupportAlerts;
|
||||
using Baya.Application.Features.Reviews.Commands.ModerateReview;
|
||||
using Baya.Application.Features.Reviews.Commands.SubmitReview;
|
||||
using Baya.Application.Features.Reviews.Queries.ListReviewsForNurse;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Reviews;
|
||||
using Baya.Domain.Entities.SupportAlerts;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Reviews;
|
||||
|
||||
public class ReviewHandlerTests
|
||||
{
|
||||
private static SubmitReviewCommandHandler SubmitHandler(
|
||||
ReviewsTestHost host, ISupportAlertService alerts, ModerationDecision decision = ModerationDecision.Flag)
|
||||
=> new(
|
||||
host.AsCustomer(), host.UnitOfWork, host.Config(), host.Moderation(decision),
|
||||
alerts, Substitute.For<ISearchIndexMaintainer>(), Substitute.For<ICacheService>(), host.Clock());
|
||||
|
||||
private static ModerateReviewCommandHandler ModerateHandler(ReviewsTestHost host)
|
||||
=> new(
|
||||
host.AsAdmin(), host.UnitOfWork, Substitute.For<ISearchIndexMaintainer>(),
|
||||
Substitute.For<ICacheService>(), host.Clock(), Substitute.For<INotificationDispatcher>());
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_on_completed_booking_creates_pending_and_not_public()
|
||||
{
|
||||
using var host = new ReviewsTestHost();
|
||||
var bookingId = host.SeedBooking(BookingStatus.Completed);
|
||||
|
||||
var result = await SubmitHandler(host, Substitute.For<ISupportAlertService>())
|
||||
.Handle(new SubmitReviewCommand(bookingId, 5, "great", null), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(ReviewModerationStatus.PendingModeration, result.Result.ModerationStatus);
|
||||
|
||||
// Publish gate: a pending review is not in the public list and not counted in the aggregate.
|
||||
var list = await new ListReviewsForNurseQueryHandler(host.UnitOfWork, new PassThroughCache())
|
||||
.Handle(new ListReviewsForNurseQuery(host.NurseId), CancellationToken.None);
|
||||
Assert.Empty(list.Result.Reviews.Items);
|
||||
Assert.Equal(0, list.Result.Aggregate.PublishedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_on_cancelled_booking_fails_and_duplicate_is_conflict()
|
||||
{
|
||||
using var host = new ReviewsTestHost();
|
||||
|
||||
var cancelled = host.SeedBooking(BookingStatus.Cancelled);
|
||||
var onCancelled = await SubmitHandler(host, Substitute.For<ISupportAlertService>())
|
||||
.Handle(new SubmitReviewCommand(cancelled, 5, null, null), CancellationToken.None);
|
||||
Assert.False(onCancelled.IsSuccess);
|
||||
Assert.False(onCancelled.IsException);
|
||||
|
||||
var completed = host.SeedBooking(BookingStatus.Completed);
|
||||
var first = await SubmitHandler(host, Substitute.For<ISupportAlertService>())
|
||||
.Handle(new SubmitReviewCommand(completed, 4, null, null), CancellationToken.None);
|
||||
Assert.True(first.IsSuccess);
|
||||
|
||||
var second = await SubmitHandler(host, Substitute.For<ISupportAlertService>())
|
||||
.Handle(new SubmitReviewCommand(completed, 3, null, null), CancellationToken.None);
|
||||
Assert.False(second.IsSuccess);
|
||||
Assert.True(second.IsConflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_by_non_owner_is_not_found()
|
||||
{
|
||||
using var host = new ReviewsTestHost();
|
||||
var bookingId = host.SeedBooking(BookingStatus.Completed);
|
||||
|
||||
// A different user with a customer role but not the booking owner (no matching customer profile).
|
||||
var stranger = new SubmitReviewCommandHandler(
|
||||
host.AsUser(4242, Domain.Entities.User.RoleNames.Customer), host.UnitOfWork, host.Config(),
|
||||
host.Moderation(), Substitute.For<ISupportAlertService>(), Substitute.For<ISearchIndexMaintainer>(),
|
||||
Substitute.For<ICacheService>(), host.Clock());
|
||||
|
||||
var result = await stranger.Handle(new SubmitReviewCommand(bookingId, 5, null, null), CancellationToken.None);
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.True(result.IsForbidden || result.IsNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Moderation_recomputes_aggregate_from_source_on_publish_and_hide()
|
||||
{
|
||||
using var host = new ReviewsTestHost();
|
||||
var high = host.SeedBooking(BookingStatus.Completed);
|
||||
var low = host.SeedBooking(BookingStatus.Completed);
|
||||
|
||||
var r5 = (await SubmitHandler(host, Substitute.For<ISupportAlertService>())
|
||||
.Handle(new SubmitReviewCommand(high, 5, "excellent", null), CancellationToken.None)).Result.Id;
|
||||
var r1 = (await SubmitHandler(host, Substitute.For<ISupportAlertService>())
|
||||
.Handle(new SubmitReviewCommand(low, 1, "poor", null), CancellationToken.None)).Result.Id;
|
||||
|
||||
// Publish both → count 2, avg 3.0.
|
||||
await ModerateHandler(host).Handle(new ModerateReviewCommand(r5, ReviewModerationAction.Publish, null), CancellationToken.None);
|
||||
var afterBoth = await ModerateHandler(host).Handle(new ModerateReviewCommand(r1, ReviewModerationAction.Publish, null), CancellationToken.None);
|
||||
Assert.Equal(2, afterBoth.Result.TotalReviews);
|
||||
Assert.Equal(3.0m, afterBoth.Result.AverageRating);
|
||||
Assert.Equal(2, host.TotalReviewsOf(host.NurseId));
|
||||
|
||||
// Hide the 1★ → count 1, avg 5.0 (re-derived from source, not stale).
|
||||
var afterHide = await ModerateHandler(host).Handle(new ModerateReviewCommand(r1, ReviewModerationAction.Hide, "off_topic"), CancellationToken.None);
|
||||
Assert.Equal(1, afterHide.Result.TotalReviews);
|
||||
Assert.Equal(5.0m, afterHide.Result.AverageRating);
|
||||
Assert.Equal(1, host.TotalReviewsOf(host.NurseId));
|
||||
Assert.Equal(5.0m, host.AverageRatingOf(host.NurseId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Low_rating_raises_support_alert()
|
||||
{
|
||||
using var host = new ReviewsTestHost();
|
||||
var bookingId = host.SeedBooking(BookingStatus.Completed);
|
||||
var alerts = Substitute.For<ISupportAlertService>();
|
||||
|
||||
var result = await SubmitHandler(host, alerts)
|
||||
.Handle(new SubmitReviewCommand(bookingId, 1, "unhappy", null), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Result.LowRatingAlertRaised);
|
||||
await alerts.Received(1).RaiseAsync(
|
||||
SupportAlertType.LowRating, "review", Arg.Any<string>(), SupportAlertSeverity.High,
|
||||
bookingId, result.Result.Id, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task High_rating_does_not_raise_alert()
|
||||
{
|
||||
using var host = new ReviewsTestHost();
|
||||
var bookingId = host.SeedBooking(BookingStatus.Completed);
|
||||
var alerts = Substitute.For<ISupportAlertService>();
|
||||
|
||||
var result = await SubmitHandler(host, alerts)
|
||||
.Handle(new SubmitReviewCommand(bookingId, 5, "great", null), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.Result.LowRatingAlertRaised);
|
||||
await alerts.DidNotReceive().RaiseAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
||||
Arg.Any<long?>(), Arg.Any<long?>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_with_tags_persists_links_and_surfaces_on_publish()
|
||||
{
|
||||
using var host = new ReviewsTestHost();
|
||||
var bookingId = host.SeedBooking(BookingStatus.Completed);
|
||||
|
||||
var submit = await SubmitHandler(host, Substitute.For<ISupportAlertService>())
|
||||
.Handle(new SubmitReviewCommand(bookingId, 5, "great", [ReviewTagCodes.Punctual, ReviewTagCodes.Kind]), CancellationToken.None);
|
||||
Assert.True(submit.IsSuccess);
|
||||
|
||||
await ModerateHandler(host).Handle(new ModerateReviewCommand(submit.Result.Id, ReviewModerationAction.Publish, null), CancellationToken.None);
|
||||
|
||||
var list = await new ListReviewsForNurseQueryHandler(host.UnitOfWork, new PassThroughCache())
|
||||
.Handle(new ListReviewsForNurseQuery(host.NurseId), CancellationToken.None);
|
||||
var item = Assert.Single(list.Result.Reviews.Items);
|
||||
Assert.Contains(ReviewTagCodes.Punctual, item.TagCodes);
|
||||
Assert.Contains(ReviewTagCodes.Kind, item.TagCodes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_with_unknown_tag_fails()
|
||||
{
|
||||
using var host = new ReviewsTestHost();
|
||||
var bookingId = host.SeedBooking(BookingStatus.Completed);
|
||||
|
||||
var result = await SubmitHandler(host, Substitute.For<ISupportAlertService>())
|
||||
.Handle(new SubmitReviewCommand(bookingId, 5, null, ["not_a_real_tag"]), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.False(result.IsException);
|
||||
}
|
||||
|
||||
/// <summary>A cache that always runs the factory — exercises the real aggregate read without caching.</summary>
|
||||
private sealed class PassThroughCache : ICacheService
|
||||
{
|
||||
public ValueTask<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult<T?>(default);
|
||||
|
||||
public ValueTask SetAsync<T>(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask<T> GetOrCreateAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
|
||||
=> factory(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Reviews;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Baya.Tests.Setup.Setups;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
|
||||
|
||||
namespace Baya.Test.Foundation.Reviews;
|
||||
|
||||
/// <summary>
|
||||
/// A self-contained SQLite host exercising the real EF model (schema, CHECK, unique indexes, seed) for the b14
|
||||
/// reviews + patient-care-records engine. Seeds one bookable nurse, a second (unassigned) nurse, one customer +
|
||||
/// patient + address, and can create a completed booking so a test can drive the real handlers against the real
|
||||
/// <see cref="UnitOfWork"/> with substituted seams.
|
||||
/// </summary>
|
||||
public sealed class ReviewsTestHost : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
public ApplicationDbContext Db { get; }
|
||||
public UnitOfWork UnitOfWork { get; }
|
||||
|
||||
public long CustomerId { get; }
|
||||
public int CustomerUserId { get; }
|
||||
public long NurseId { get; }
|
||||
public int NurseUserId { get; }
|
||||
public long OtherNurseId { get; }
|
||||
public int OtherNurseUserId { get; }
|
||||
public long PatientId { get; }
|
||||
|
||||
private readonly long _cityId;
|
||||
private readonly long _categoryId;
|
||||
private readonly long _addressId;
|
||||
|
||||
public ReviewsTestHost()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
|
||||
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
|
||||
Db.Database.EnsureCreated();
|
||||
UnitOfWork = new UnitOfWork(Db);
|
||||
|
||||
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
|
||||
Db.Set<Province>().Add(province);
|
||||
Db.SaveChanges();
|
||||
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
|
||||
Db.Set<City>().Add(city);
|
||||
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
|
||||
Db.Set<ServiceCategory>().Add(category);
|
||||
Db.SaveChanges();
|
||||
_cityId = city.Id;
|
||||
_categoryId = category.Id;
|
||||
|
||||
var customerUser = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
|
||||
Db.Users.Add(customerUser);
|
||||
Db.SaveChanges();
|
||||
CustomerUserId = customerUser.Id;
|
||||
var customer = new CustomerProfile { UserId = customerUser.Id };
|
||||
Db.Set<CustomerProfile>().Add(customer);
|
||||
Db.SaveChanges();
|
||||
CustomerId = customer.Id;
|
||||
|
||||
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true };
|
||||
Db.Set<Patient>().Add(patient);
|
||||
var address = new CustomerAddress
|
||||
{
|
||||
CustomerId = customer.Id, CityId = city.Id, Title = "خانه", AddressLine = "خیابان اول",
|
||||
PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001",
|
||||
Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true
|
||||
};
|
||||
Db.Set<CustomerAddress>().Add(address);
|
||||
Db.SaveChanges();
|
||||
PatientId = patient.Id;
|
||||
_addressId = address.Id;
|
||||
|
||||
(NurseId, NurseUserId) = SeedNurse("nurse1", "09120000002");
|
||||
(OtherNurseId, OtherNurseUserId) = SeedNurse("nurse2", "09120000003");
|
||||
}
|
||||
|
||||
private (long NurseId, int UserId) SeedNurse(string userName, string phone)
|
||||
{
|
||||
var nurseUser = new User { UserName = userName, PhoneNumber = phone, Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true };
|
||||
Db.Users.Add(nurseUser);
|
||||
Db.SaveChanges();
|
||||
var nurse = new NurseProfile { UserId = nurseUser.Id };
|
||||
nurse.MarkVerified();
|
||||
nurse.SetAcceptingBookings(true);
|
||||
Db.Set<NurseProfile>().Add(nurse);
|
||||
Db.SaveChanges();
|
||||
return (nurse.Id, nurseUser.Id);
|
||||
}
|
||||
|
||||
/// <summary>Seeds a booking for the seeded nurse + patient in the given status (default completed).</summary>
|
||||
public long SeedBooking(string status = BookingStatus.Completed, long? nurseId = null)
|
||||
{
|
||||
var owningNurse = nurseId ?? NurseId;
|
||||
var variant = new NurseServiceVariant
|
||||
{
|
||||
NurseId = owningNurse, ServiceCategoryId = _categoryId, Price = 10_000_000, PriceUnit = "per_day",
|
||||
SessionCount = null, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true
|
||||
};
|
||||
Db.Set<NurseServiceVariant>().Add(variant);
|
||||
Db.SaveChanges();
|
||||
|
||||
var request = new BookingRequest
|
||||
{
|
||||
CustomerId = CustomerId, NurseId = owningNurse, PatientId = PatientId, VariantId = variant.Id,
|
||||
CustomerAddressId = _addressId, RequiredCaregiverGender = CaregiverGender.Any,
|
||||
RequestedDate = new DateOnly(2026, 8, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0),
|
||||
CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
Db.Set<BookingRequest>().Add(request);
|
||||
Db.SaveChanges();
|
||||
request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
Db.SaveChanges();
|
||||
|
||||
var booking = new BookingEntity
|
||||
{
|
||||
BookingRequestId = request.Id, CustomerId = CustomerId, NurseId = owningNurse, PatientId = PatientId,
|
||||
VariantId = variant.Id, CustomerAddressId = _addressId, VariantSnapshotJson = "{}", AddressSnapshotJson = "{}",
|
||||
GrossPriceIrr = 10_000_000, BalinyaarCommissionIrr = 1_500_000, PlatformFeeRate = 0.15m,
|
||||
NursePayoutAmount = 8_500_000, SessionCount = 1,
|
||||
ScheduledDate = new DateOnly(2026, 8, 1), ScheduledTimeStart = new TimeOnly(9, 0), ScheduledTimeEnd = new TimeOnly(13, 0)
|
||||
};
|
||||
var now = new DateTime(2026, 8, 2, 0, 0, 0, DateTimeKind.Utc);
|
||||
WalkTo(booking, status, now);
|
||||
Db.Set<BookingEntity>().Add(booking);
|
||||
Db.SaveChanges();
|
||||
|
||||
return booking.Id;
|
||||
}
|
||||
|
||||
private static void WalkTo(BookingEntity booking, string status, DateTime now)
|
||||
{
|
||||
if (status == BookingStatus.PendingPayment)
|
||||
return;
|
||||
|
||||
booking.TransitionTo(BookingStatus.Confirmed, now);
|
||||
if (status == BookingStatus.Confirmed)
|
||||
return;
|
||||
|
||||
// Cancelled is only legal from confirmed/in-progress, never from a completed booking.
|
||||
if (status == BookingStatus.Cancelled)
|
||||
{
|
||||
booking.TransitionTo(BookingStatus.Cancelled, now, actor: CancellationActor.Customer, reason: "test");
|
||||
return;
|
||||
}
|
||||
|
||||
booking.TransitionTo(BookingStatus.InProgress, now);
|
||||
if (status == BookingStatus.InProgress)
|
||||
return;
|
||||
|
||||
booking.TransitionTo(BookingStatus.Completed, now);
|
||||
if (status is BookingStatus.Closed or BookingStatus.Disputed)
|
||||
booking.TransitionTo(status, now);
|
||||
}
|
||||
|
||||
public decimal AverageRatingOf(long nurseId)
|
||||
=> Db.Set<NurseProfile>().AsNoTracking().Where(p => p.Id == nurseId).Select(p => p.AverageRating).First();
|
||||
|
||||
public int TotalReviewsOf(long nurseId)
|
||||
=> Db.Set<NurseProfile>().AsNoTracking().Where(p => p.Id == nurseId).Select(p => p.TotalReviews).First();
|
||||
|
||||
public ICurrentUser AsCustomer() => User(CustomerUserId, RoleNames.Customer);
|
||||
public ICurrentUser AsNurse() => User(NurseUserId, RoleNames.Nurse);
|
||||
public ICurrentUser AsOtherNurse() => User(OtherNurseUserId, RoleNames.Nurse);
|
||||
public ICurrentUser AsAdmin(int userId = 9999) => User(userId, RoleNames.Admin);
|
||||
public ICurrentUser AsUser(int userId, params string[] roles) => User(userId, roles);
|
||||
|
||||
private static ICurrentUser User(int userId, params string[] roles)
|
||||
{
|
||||
var u = Substitute.For<ICurrentUser>();
|
||||
u.UserId.Returns(userId);
|
||||
u.IsAuthenticated.Returns(true);
|
||||
u.Roles.Returns(roles);
|
||||
return u;
|
||||
}
|
||||
|
||||
public IDateTimeProvider Clock() => Clock(new DateTimeOffset(2026, 8, 3, 10, 0, 0, TimeSpan.Zero));
|
||||
|
||||
public IDateTimeProvider Clock(DateTimeOffset now)
|
||||
{
|
||||
var c = Substitute.For<IDateTimeProvider>();
|
||||
c.UtcNow.Returns(now);
|
||||
return c;
|
||||
}
|
||||
|
||||
public IPlatformConfig Config(decimal lowRatingThreshold = 2m)
|
||||
{
|
||||
var cfg = Substitute.For<IPlatformConfig>();
|
||||
cfg.GetConfig<decimal>("min_rating_for_support_alert", Arg.Any<CancellationToken>()).Returns(lowRatingThreshold);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
public IReviewModerationService Moderation(ModerationDecision decision = ModerationDecision.Flag)
|
||||
{
|
||||
var m = Substitute.For<IReviewModerationService>();
|
||||
m.ScreenAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ModerationVerdict(decision, "test"));
|
||||
return m;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Db.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user