backend phase 14 & frontend phase 7

This commit is contained in:
hamid
2026-07-09 15:30:03 +03:30
parent de53f9d8a6
commit 93cc5ecb98
101 changed files with 12930 additions and 39 deletions
@@ -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);
}
@@ -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);
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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>>>;
@@ -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));
}
}
@@ -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);
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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));
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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);
}
}
@@ -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 15 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>>;
@@ -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);
}
}
@@ -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>>>;
@@ -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);
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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>15, 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];
}