backend phase 14 & frontend phase 7
This commit is contained in:
+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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user