[Verify+Complaints] Facility document review + facility complaints; card location line
CI/CD / CI · dotnet build (push) Successful in 1m27s
CI/CD / Deploy · hamkadr (push) Successful in 1m13s

Card: move location to its own line above the date in the shift card (job card already did). Verification workflow: employers upload documents (license/permit) on a new Employer/Verify page; uploading marks the facility Pending. Admins see pending facilities with their documents on Admin/Facilities, can download each doc, and approve (تأیید شد) or reject with a reason. Documents stored as bytea in the DB (survives deploys via the existing volume); served only to the owner or an admin via /facility-doc/{id}. Facility model gains Verification status enum + note + requested-at; IsVerified kept in sync. Complaints: registered users/visitors can file a شکایت about a facility from shift/job detail pages (targets ReportTargetType.Facility, surfaces in Admin/Reports as مرکز). Migration backfills existing verified facilities to Verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 16:26:15 +03:30
parent 962196d5cb
commit 1f34fd126f
16 changed files with 1632 additions and 24 deletions
+6
View File
@@ -24,6 +24,7 @@ public class AppDbContext : DbContext
public DbSet<WebPushSubscription> WebPushSubscriptions => Set<WebPushSubscription>(); public DbSet<WebPushSubscription> WebPushSubscriptions => Set<WebPushSubscription>();
public DbSet<Notification> Notifications => Set<Notification>(); public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<Report> Reports => Set<Report>(); public DbSet<Report> Reports => Set<Report>();
public DbSet<FacilityDocument> FacilityDocuments => Set<FacilityDocument>();
protected override void OnModelCreating(ModelBuilder b) protected override void OnModelCreating(ModelBuilder b)
{ {
@@ -77,6 +78,11 @@ public class AppDbContext : DbContext
.HasOne(f => f.District).WithMany(d => d.Facilities) .HasOne(f => f.District).WithMany(d => d.Facilities)
.HasForeignKey(f => f.DistrictId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(f => f.DistrictId).OnDelete(DeleteBehavior.SetNull);
// Verification documents belong to a facility; remove them with it.
b.Entity<FacilityDocument>()
.HasOne(d => d.Facility).WithMany(f => f.Documents)
.HasForeignKey(d => d.FacilityId).OnDelete(DeleteBehavior.Cascade);
// Don't delete shifts/profiles just because a Role is removed. // Don't delete shifts/profiles just because a Role is removed.
b.Entity<Shift>() b.Entity<Shift>()
.HasOne(s => s.Role).WithMany(r => r.Shifts) .HasOne(s => s.Role).WithMany(r => r.Shifts)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class FacilityVerificationDocs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Verification",
table: "Facilities",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "VerificationNote",
table: "Facilities",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "VerificationRequestedAt",
table: "Facilities",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateTable(
name: "FacilityDocuments",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
FacilityId = table.Column<int>(type: "integer", nullable: false),
FileName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
ContentType = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Size = table.Column<long>(type: "bigint", nullable: false),
Data = table.Column<byte[]>(type: "bytea", nullable: false),
UploadedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FacilityDocuments", x => x.Id);
table.ForeignKey(
name: "FK_FacilityDocuments_Facilities_FacilityId",
column: x => x.FacilityId,
principalTable: "Facilities",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_FacilityDocuments_FacilityId",
table: "FacilityDocuments",
column: "FacilityId");
// Backfill: already-verified facilities get Verification = Verified (2).
migrationBuilder.Sql("UPDATE \"Facilities\" SET \"Verification\" = 2 WHERE \"IsVerified\" = true;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FacilityDocuments");
migrationBuilder.DropColumn(
name: "Verification",
table: "Facilities");
migrationBuilder.DropColumn(
name: "VerificationNote",
table: "Facilities");
migrationBuilder.DropColumn(
name: "VerificationRequestedAt",
table: "Facilities");
}
}
}
@@ -337,6 +337,16 @@ namespace JobsMedical.Web.Migrations
b.Property<int>("Type") b.Property<int>("Type")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int>("Verification")
.HasColumnType("integer");
b.Property<string>("VerificationNote")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime?>("VerificationRequestedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CityId"); b.HasIndex("CityId");
@@ -348,6 +358,44 @@ namespace JobsMedical.Web.Migrations
b.ToTable("Facilities"); b.ToTable("Facilities");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.FacilityDocument", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<byte[]>("Data")
.IsRequired()
.HasColumnType("bytea");
b.Property<int>("FacilityId")
.HasColumnType("integer");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<long>("Size")
.HasColumnType("bigint");
b.Property<DateTime>("UploadedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("FacilityId");
b.ToTable("FacilityDocuments");
});
modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b => modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -902,6 +950,17 @@ namespace JobsMedical.Web.Migrations
b.Navigation("OwnerUser"); b.Navigation("OwnerUser");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.FacilityDocument", b =>
{
b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
.WithMany("Documents")
.HasForeignKey("FacilityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Facility");
});
modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b => modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b =>
{ {
b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening") b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening")
@@ -1030,6 +1089,8 @@ namespace JobsMedical.Web.Migrations
modelBuilder.Entity("JobsMedical.Web.Models.Facility", b => modelBuilder.Entity("JobsMedical.Web.Models.Facility", b =>
{ {
b.Navigation("Documents");
b.Navigation("Shifts"); b.Navigation("Shifts");
}); });
+3
View File
@@ -96,3 +96,6 @@ public enum IngestionMode
public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 } public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 }
public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 } public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 }
/// <summary>Facility verification lifecycle. Facility.IsVerified stays in sync (true only when Verified).</summary>
public enum VerificationStatus { Unverified = 0, Pending = 1, Verified = 2, Rejected = 3 }
+9 -1
View File
@@ -33,7 +33,12 @@ public class Facility
[MaxLength(50)] [MaxLength(50)]
public string? BaleId { get; set; } // شناسه بله برای ارتباط public string? BaleId { get; set; } // شناسه بله برای ارتباط
public bool IsVerified { get; set; } // نشان «تأیید شده» public bool IsVerified { get; set; } // نشان «تأیید شده» (true only when Verification == Verified)
/// <summary>Verification workflow: employer requests review (+docs) → admin approves/rejects.</summary>
public VerificationStatus Verification { get; set; } = VerificationStatus.Unverified;
[MaxLength(500)] public string? VerificationNote { get; set; } // admin reject reason / note
public DateTime? VerificationRequestedAt { get; set; }
// Phase 2: facility self-serve. Null in MVP (admin manages). // Phase 2: facility self-serve. Null in MVP (admin manages).
public int? OwnerUserId { get; set; } public int? OwnerUserId { get; set; }
@@ -42,4 +47,7 @@ public class Facility
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<Shift> Shifts { get; set; } = new List<Shift>(); public ICollection<Shift> Shifts { get; set; } = new List<Shift>();
/// <summary>Documents the employer uploaded to prove the facility is real (license, etc.).</summary>
public ICollection<FacilityDocument> Documents { get; set; } = new List<FacilityDocument>();
} }
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>
/// A verification document an employer uploads for their facility (license, permit, ID…).
/// Stored as bytes in the DB so it survives deploys via the existing Postgres volume/backups
/// (no separate file volume to mount). Only the facility owner and admins can read it back.
/// </summary>
public class FacilityDocument
{
public int Id { get; set; }
public int FacilityId { get; set; }
public Facility Facility { get; set; } = null!;
[MaxLength(200)] public string FileName { get; set; } = "";
[MaxLength(120)] public string ContentType { get; set; } = "application/octet-stream";
public long Size { get; set; }
/// <summary>Raw file bytes (images/PDF). Capped at the upload handler (a few MB).</summary>
public byte[] Data { get; set; } = Array.Empty<byte>();
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
}
@@ -15,25 +15,27 @@
<h1>تأیید مراکز درمانی</h1> <h1>تأیید مراکز درمانی</h1>
<p class="muted"> <p class="muted">
<a asp-page="/Admin/Index">← صف آگهی‌ها</a> <a asp-page="/Admin/Index">← صف آگهی‌ها</a>
· @JalaliDate.ToPersianDigits(Model.Pending.Count.ToString()) مرکز در انتظار تأیید · @JalaliDate.ToPersianDigits(Model.Awaiting.Count.ToString()) مرکز منتظر بررسی
</p> </p>
</div> </div>
</div> </div>
<div class="container section"> <div class="container section">
<h2 style="font-size:20px;">در انتظار تأیید</h2> @if (Model.Msg is not null) { <div class="alert alert-success">@Model.Msg</div> }
@if (Model.Pending.Count == 0)
<h2 style="font-size:20px;">منتظر بررسی (مدارک ارسال‌شده)</h2>
@if (Model.Awaiting.Count == 0)
{ {
<div class="card empty-state">مرکزی در انتظار تأیید نیست.</div> <div class="card empty-state">مرکزی منتظر بررسی نیست.</div>
} }
else else
{ {
foreach (var f in Model.Pending) foreach (var f in Model.Awaiting)
{ {
<div class="card card-pad" style="margin-bottom:10px;"> <div class="card card-pad" style="margin-bottom:10px;">
<div class="row" style="display:flex; justify-content:space-between; align-items:center; gap:10px;"> <div class="row" style="display:flex; justify-content:space-between; align-items:flex-start; gap:10px;">
<div> <div>
<strong>@f.Name</strong> — @TypeLabel(f.Type) <strong>@f.Name</strong> — @TypeLabel(f.Type) <span class="badge badge-type">در حال بررسی</span>
<div class="muted" style="font-size:13px;"> <div class="muted" style="font-size:13px;">
📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "") 📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")
@if (f.OwnerUser is not null) { <text> · مالک: <span dir="ltr">@JalaliDate.ToPersianDigits(f.OwnerUser.Phone)</span></text> } @if (f.OwnerUser is not null) { <text> · مالک: <span dir="ltr">@JalaliDate.ToPersianDigits(f.OwnerUser.Phone)</span></text> }
@@ -41,8 +43,32 @@
</div> </div>
@if (!string.IsNullOrEmpty(f.Address)) { <div class="muted" style="font-size:13px;">@f.Address</div> } @if (!string.IsNullOrEmpty(f.Address)) { <div class="muted" style="font-size:13px;">@f.Address</div> }
</div> </div>
</div>
<div style="margin:10px 0;">
<strong style="font-size:13px;">مدارک (@JalaliDate.ToPersianDigits(f.Documents.Count.ToString())):</strong>
@if (f.Documents.Count == 0)
{
<span class="muted" style="font-size:13px;"> — مدرکی بارگذاری نشده.</span>
}
else
{
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:6px;">
@foreach (var d in f.Documents)
{
<a class="btn btn-outline" style="padding:6px 12px; font-size:13px;" href="/facility-doc/@d.Id" target="_blank">📎 @d.FileName</a>
}
</div>
}
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:flex-end;">
<form method="post"> <form method="post">
<button asp-page-handler="Verify" asp-route-id="@f.Id" class="btn btn-accent" style="white-space:nowrap;">✓ تأیید</button> <button asp-page-handler="Verify" asp-route-id="@f.Id" class="btn btn-accent" style="white-space:nowrap;">✓ تأیید شد</button>
</form>
<form method="post" style="display:flex; gap:6px; flex:1; min-width:220px;">
<input type="text" name="note" placeholder="دلیل رد (اختیاری)" style="flex:1;" />
<button asp-page-handler="Reject" asp-route-id="@f.Id" class="btn btn-outline" style="white-space:nowrap; color:var(--danger); border-color:var(--danger);">رد</button>
</form> </form>
</div> </div>
</div> </div>
@@ -71,4 +97,24 @@
</div> </div>
} }
} }
@if (Model.Others.Count > 0)
{
<h2 style="font-size:20px; margin-top:30px;">سایر مراکز (بدون درخواست تأیید)</h2>
foreach (var f in Model.Others)
{
<div class="card card-pad" style="margin-bottom:10px;">
<div class="row" style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
<div>
<strong>@f.Name</strong> — @TypeLabel(f.Type)
@if (f.Verification == JobsMedical.Web.Models.VerificationStatus.Rejected) { <span class="badge badge-gender">رد شده</span> }
<div class="muted" style="font-size:13px;">📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")</div>
</div>
<form method="post">
<button asp-page-handler="Verify" asp-route-id="@f.Id" class="btn btn-accent" style="white-space:nowrap;">✓ تأیید مستقیم</button>
</form>
</div>
</div>
}
}
</div> </div>
@@ -13,20 +13,45 @@ public class FacilitiesModel : PageModel
private readonly AppDbContext _db; private readonly AppDbContext _db;
public FacilitiesModel(AppDbContext db) => _db = db; public FacilitiesModel(AppDbContext db) => _db = db;
public List<Facility> Pending { get; private set; } = new(); public List<Facility> Awaiting { get; private set; } = new(); // requested review (Pending)
public List<Facility> Others { get; private set; } = new(); // unverified / rejected, no pending request
public List<Facility> Verified { get; private set; } = new(); public List<Facility> Verified { get; private set; } = new();
[TempData] public string? Msg { get; set; }
public async Task OnGetAsync() => await LoadAsync(); public async Task OnGetAsync() => await LoadAsync();
public async Task<IActionResult> OnPostVerifyAsync(int id) => await SetVerified(id, true); public async Task<IActionResult> OnPostVerifyAsync(int id)
public async Task<IActionResult> OnPostUnverifyAsync(int id) => await SetVerified(id, false);
private async Task<IActionResult> SetVerified(int id, bool value)
{ {
var f = await _db.Facilities.FindAsync(id); var f = await _db.Facilities.FindAsync(id);
if (f is null) return NotFound(); if (f is null) return NotFound();
f.IsVerified = value; f.IsVerified = true;
f.Verification = VerificationStatus.Verified;
f.VerificationNote = null;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
Msg = $"«{f.Name}» تأیید شد.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostRejectAsync(int id, string? note)
{
var f = await _db.Facilities.FindAsync(id);
if (f is null) return NotFound();
f.IsVerified = false;
f.Verification = VerificationStatus.Rejected;
f.VerificationNote = string.IsNullOrWhiteSpace(note) ? "مدارک کافی نبود." : note.Trim();
await _db.SaveChangesAsync();
Msg = $"«{f.Name}» رد شد.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostUnverifyAsync(int id)
{
var f = await _db.Facilities.FindAsync(id);
if (f is null) return NotFound();
f.IsVerified = false;
f.Verification = VerificationStatus.Unverified;
await _db.SaveChangesAsync();
Msg = $"تأیید «{f.Name}» لغو شد.";
return RedirectToPage(); return RedirectToPage();
} }
@@ -34,8 +59,10 @@ public class FacilitiesModel : PageModel
{ {
var all = await _db.Facilities var all = await _db.Facilities
.Include(f => f.City).Include(f => f.District).Include(f => f.OwnerUser) .Include(f => f.City).Include(f => f.District).Include(f => f.OwnerUser)
.OrderBy(f => f.Name).ToListAsync(); .Include(f => f.Documents)
Pending = all.Where(f => !f.IsVerified).ToList(); .OrderByDescending(f => f.VerificationRequestedAt).ThenBy(f => f.Name).ToListAsync();
Awaiting = all.Where(f => f.Verification == VerificationStatus.Pending).ToList();
Verified = all.Where(f => f.IsVerified).ToList(); Verified = all.Where(f => f.IsVerified).ToList();
Others = all.Where(f => !f.IsVerified && f.Verification != VerificationStatus.Pending).ToList();
} }
} }
@@ -45,13 +45,20 @@
<div class="card card-pad"> <div class="card card-pad">
<div class="row" style="display:flex; justify-content:space-between; align-items:center;"> <div class="row" style="display:flex; justify-content:space-between; align-items:center;">
<span class="facility" style="font-weight:800; font-size:16px;">@c.Facility.Name</span> <span class="facility" style="font-weight:800; font-size:16px;">@c.Facility.Name</span>
@if (c.Facility.IsVerified) @switch (c.Facility.Verification)
{ {
<span class="badge badge-verified">✓ تأیید شده</span> case JobsMedical.Web.Models.VerificationStatus.Verified:
} <span class="badge badge-verified">✓ تأیید شده</span>
else break;
{ case JobsMedical.Web.Models.VerificationStatus.Pending:
<span class="badge badge-type">در انتظار تأیید</span> <span class="badge badge-type">در حال بررسی</span>
break;
case JobsMedical.Web.Models.VerificationStatus.Rejected:
<span class="badge badge-gender">رد شده</span>
break;
default:
<span class="badge badge-type">تأیید نشده</span>
break;
} }
</div> </div>
<p class="muted" style="margin:8px 0;"> <p class="muted" style="margin:8px 0;">
@@ -61,6 +68,12 @@
<div class="info-row"><span class="k">موقعیت‌های استخدامی</span><span class="v">@JalaliDate.ToPersianDigits(c.OpenJobs.ToString())</span></div> <div class="info-row"><span class="k">موقعیت‌های استخدامی</span><span class="v">@JalaliDate.ToPersianDigits(c.OpenJobs.ToString())</span></div>
<div class="info-row"><span class="k">اعلام تمایل‌ها</span><span class="v" style="color:var(--accent)">@JalaliDate.ToPersianDigits(c.Applicants.ToString())</span></div> <div class="info-row"><span class="k">اعلام تمایل‌ها</span><span class="v" style="color:var(--accent)">@JalaliDate.ToPersianDigits(c.Applicants.ToString())</span></div>
<a class="btn btn-outline btn-block" style="margin-top:12px;" asp-page="/Employer/Listings" asp-route-facilityId="@c.Facility.Id">مدیریت آگهی‌ها و متقاضیان</a> <a class="btn btn-outline btn-block" style="margin-top:12px;" asp-page="/Employer/Listings" asp-route-facilityId="@c.Facility.Id">مدیریت آگهی‌ها و متقاضیان</a>
@if (c.Facility.Verification != JobsMedical.Web.Models.VerificationStatus.Verified)
{
<a class="btn btn-outline btn-block" style="margin-top:8px;" asp-page="/Employer/Verify" asp-route-id="@c.Facility.Id">
@(c.Facility.Verification == JobsMedical.Web.Models.VerificationStatus.Pending ? "مشاهده/افزودن مدارک تأیید" : "درخواست تأیید و بارگذاری مدارک")
</a>
}
</div> </div>
} }
</div> </div>
@@ -0,0 +1,85 @@
@page "{id:int}"
@model JobsMedical.Web.Pages.Employer.VerifyModel
@{
ViewData["Title"] = "تأیید مرکز درمانی";
var v = Model.Facility.Verification;
}
<div class="page-head">
<div class="container">
<h1>تأیید مرکز: @Model.Facility.Name</h1>
<p class="muted"><a asp-page="/Employer/Index">← بازگشت به پنل</a></p>
</div>
</div>
<div class="container section" style="max-width:680px;">
@if (Model.Msg is not null) { <div class="alert alert-success">@Model.Msg</div> }
<div class="card card-pad" style="margin-bottom:14px;">
<div class="row" style="display:flex; justify-content:space-between; align-items:center;">
<strong>وضعیت تأیید</strong>
@switch (v)
{
case JobsMedical.Web.Models.VerificationStatus.Verified:
<span class="badge badge-verified">✓ تأیید شده</span>
break;
case JobsMedical.Web.Models.VerificationStatus.Pending:
<span class="badge badge-type">در حال بررسی</span>
break;
case JobsMedical.Web.Models.VerificationStatus.Rejected:
<span class="badge badge-gender">رد شده</span>
break;
default:
<span class="badge badge-type">تأیید نشده</span>
break;
}
</div>
@if (v == JobsMedical.Web.Models.VerificationStatus.Rejected && !string.IsNullOrWhiteSpace(Model.Facility.VerificationNote))
{
<p class="muted" style="margin:8px 0 0; color:var(--danger);">دلیل رد: @Model.Facility.VerificationNote — می‌توانید مدارک اصلاح‌شده را دوباره بارگذاری کنید.</p>
}
else if (v == JobsMedical.Web.Models.VerificationStatus.Pending)
{
<p class="muted" style="margin:8px 0 0;">مدارک شما ارسال شد و توسط تیم پشتیبانی بررسی می‌شود.</p>
}
else if (v == JobsMedical.Web.Models.VerificationStatus.Verified)
{
<p class="muted" style="margin:8px 0 0;">این مرکز تأیید شده و نشان «✓ تأیید شده» را در آگهی‌ها نمایش می‌دهد.</p>
}
</div>
<div class="card card-pad" style="margin-bottom:14px;">
<h3 style="margin-top:0;">بارگذاری مدارک</h3>
<p class="muted" style="font-size:13px; margin-top:0;">مجوز فعالیت، پروانه مطب/مرکز، یا هر سندی که واقعی‌بودن مرکز را نشان دهد. تصویر (JPG/PNG/WebP) یا PDF، حداکثر ۵ مگابایت برای هر فایل.</p>
<form method="post" asp-page-handler="Upload" asp-route-id="@Model.Facility.Id" enctype="multipart/form-data">
<div class="filter-group">
<input type="file" name="files" multiple accept="image/jpeg,image/png,image/webp,application/pdf" />
</div>
<button type="submit" class="btn btn-accent btn-block btn-lg">بارگذاری و ارسال برای بررسی</button>
</form>
</div>
<div class="card card-pad">
<h3 style="margin-top:0;">مدارک بارگذاری‌شده (@JalaliDate.ToPersianDigits(Model.Docs.Count.ToString()))</h3>
@if (Model.Docs.Count == 0)
{
<p class="muted">هنوز مدرکی بارگذاری نشده است.</p>
}
else
{
<div style="display:flex; flex-direction:column; gap:8px;">
@foreach (var d in Model.Docs)
{
<div class="info-row" style="align-items:center;">
<span class="k"><a href="/facility-doc/@d.Id" target="_blank">@d.FileName</a> <span class="muted" style="font-size:12px;">(@JalaliDate.ToPersianDigits((d.Size / 1024).ToString()) کیلوبایت)</span></span>
<span class="v">
<form method="post" asp-page-handler="DeleteDoc" asp-route-id="@Model.Facility.Id" asp-route-docId="@d.Id" style="display:inline;" onsubmit="return confirm('حذف این مدرک؟');">
<button type="submit" class="btn btn-outline" style="padding:4px 12px; color:var(--danger); border-color:var(--danger);">حذف</button>
</form>
</span>
</div>
}
</div>
}
</div>
</div>
@@ -0,0 +1,85 @@
using System.Security.Claims;
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Employer;
[Authorize]
public class VerifyModel : PageModel
{
private readonly AppDbContext _db;
public VerifyModel(AppDbContext db) => _db = db;
public Facility Facility { get; private set; } = null!;
public List<FacilityDocument> Docs { get; private set; } = new();
[TempData] public string? Msg { get; set; }
private static readonly string[] Allowed = { "image/jpeg", "image/png", "image/webp", "application/pdf" };
private const long MaxBytes = 5 * 1024 * 1024; // 5 MB per file
private int Uid => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public async Task<IActionResult> OnGetAsync(int id)
{
var f = await _db.Facilities.Include(x => x.City).Include(x => x.District)
.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == Uid);
if (f is null) return NotFound();
Facility = f;
Docs = await _db.FacilityDocuments.Where(d => d.FacilityId == id)
.OrderByDescending(d => d.UploadedAt).ToListAsync();
return Page();
}
public async Task<IActionResult> OnPostUploadAsync(int id, List<IFormFile> files)
{
var f = await _db.Facilities.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == Uid);
if (f is null) return NotFound();
int added = 0;
foreach (var file in files ?? new())
{
if (file.Length == 0) continue;
if (file.Length > MaxBytes) { Msg = "هر فایل باید کمتر از ۵ مگابایت باشد."; continue; }
var ct = (file.ContentType ?? "").ToLowerInvariant();
if (!Allowed.Contains(ct)) { Msg = "فقط تصویر (JPG/PNG/WebP) یا PDF مجاز است."; continue; }
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
_db.FacilityDocuments.Add(new FacilityDocument
{
FacilityId = id,
FileName = Path.GetFileName(file.FileName),
ContentType = ct,
Size = file.Length,
Data = ms.ToArray(),
});
added++;
}
if (added > 0)
{
if (f.Verification != VerificationStatus.Verified)
{
f.Verification = VerificationStatus.Pending; // submitting docs = request review
f.VerificationRequestedAt = DateTime.UtcNow;
f.VerificationNote = null;
}
await _db.SaveChangesAsync();
Msg = $"{added} سند بارگذاری و برای بررسی ارسال شد.";
}
return RedirectToPage(new { id });
}
public async Task<IActionResult> OnPostDeleteDocAsync(int id, int docId)
{
var f = await _db.Facilities.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == Uid);
if (f is null) return NotFound();
var d = await _db.FacilityDocuments.FirstOrDefaultAsync(x => x.Id == docId && x.FacilityId == id);
if (d is not null) { _db.FacilityDocuments.Remove(d); await _db.SaveChangesAsync(); }
return RedirectToPage(new { id });
}
}
@@ -106,6 +106,20 @@
<button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ارسال گزارش</button> <button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ارسال گزارش</button>
</form> </form>
</details> </details>
@if (j.Facility is not null)
{
<details style="margin-top:6px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این مرکز (@j.Facility.Name)</summary>
<form method="post" action="/report" style="margin-top:8px;">
<input type="hidden" name="targetType" value="Facility" />
<input type="hidden" name="targetId" value="@j.Facility.Id" />
<input type="hidden" name="label" value="@j.Facility.Name" />
<input type="hidden" name="returnUrl" value="/Jobs/Details/@j.Id" />
<textarea name="reason" rows="2" placeholder="شکایت یا گزارش درباره این مرکز..." required></textarea>
<button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ثبت شکایت</button>
</form>
</details>
}
} }
</div> </div>
</aside> </aside>
@@ -22,12 +22,12 @@
{ {
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span> <span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span>
} }
<span>📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</span>
@if (Model.Facility?.IsVerified == true) @if (Model.Facility?.IsVerified == true)
{ {
<span class="badge badge-verified">✓ تأیید شده</span> <span class="badge badge-verified">✓ تأیید شده</span>
} }
</div> </div>
<div class="row loc-row">📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</div>
@if (Model.DistanceKm is double km) @if (Model.DistanceKm is double km)
{ {
<div class="row"><span class="badge badge-distance">📍 @JalaliDate.ToPersianDigits(km.ToString("0.#")) کیلومتر از شما</span></div> <div class="row"><span class="badge badge-distance">📍 @JalaliDate.ToPersianDigits(km.ToString("0.#")) کیلومتر از شما</span></div>
@@ -125,6 +125,17 @@
<button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ارسال گزارش</button> <button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ارسال گزارش</button>
</form> </form>
</details> </details>
<details style="margin-top:6px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این مرکز (@f.Name)</summary>
<form method="post" action="/report" style="margin-top:8px;">
<input type="hidden" name="targetType" value="Facility" />
<input type="hidden" name="targetId" value="@f.Id" />
<input type="hidden" name="label" value="@f.Name" />
<input type="hidden" name="returnUrl" value="/Shifts/Details/@s.Id" />
<textarea name="reason" rows="2" placeholder="شکایت یا گزارش درباره این مرکز..." required></textarea>
<button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ثبت شکایت</button>
</form>
</details>
} }
</div> </div>
+11
View File
@@ -208,6 +208,17 @@ app.MapGet("/notifications/stream", async (HttpContext ctx, NotificationHub hub)
finally { unsubscribe(); } finally { unsubscribe(); }
}).RequireAuthorization(); }).RequireAuthorization();
// Serve a facility verification document — only the facility owner or an admin may read it.
app.MapGet("/facility-doc/{id:int}", async (int id, HttpContext ctx, AppDbContext db) =>
{
var doc = await db.FacilityDocuments.Include(d => d.Facility).FirstOrDefaultAsync(d => d.Id == id);
if (doc is null) return Results.NotFound();
var isAdmin = ctx.User.IsInRole("Admin");
var uid = int.TryParse(ctx.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
if (!isAdmin && doc.Facility.OwnerUserId != uid) return Results.Forbid();
return Results.File(doc.Data, doc.ContentType, doc.FileName);
}).RequireAuthorization();
// User-submitted report against a listing (abuse/fake/wrong info). // User-submitted report against a listing (abuse/fake/wrong info).
app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext vc, app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext vc,
[FromForm] string targetType, [FromForm] int targetId, [FromForm] string reason, [FromForm] string targetType, [FromForm] int targetId, [FromForm] string reason,