Admin suite: monitoring dashboard, user management/ban, broadcast, reports, SMS test
CI/CD / CI · dotnet build (push) Failing after 1m40s
CI/CD / Deploy · hamkadr (push) Has been skipped

- /Admin/Overview: platform monitoring stats (users by role, facilities, listings, applies, push subs, queue, reports, bans)
- /Admin/Users: search/filter + ban/unban (User.IsBanned + reason); banned users blocked at login
- /Admin/Broadcast: send announcement (in-app + web push) to all / staff / employers via NotificationService
- Reports: report button on shift/job detail → /report endpoint → /Admin/Reports (resolve/dismiss)
- Settings: 'send test SMS' button; admin cross-nav links; SMS API config already in place
- migration AdminBanReports; verified overview/users/broadcast/report persist

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 13:19:20 +03:30
parent b46bd49c32
commit eae38373b9
26 changed files with 1689 additions and 4 deletions
+2
View File
@@ -23,6 +23,7 @@ public class AppDbContext : DbContext
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
public DbSet<WebPushSubscription> WebPushSubscriptions => Set<WebPushSubscription>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<Report> Reports => Set<Report>();
protected override void OnModelCreating(ModelBuilder b)
{
@@ -118,6 +119,7 @@ public class AppDbContext : DbContext
.HasOne(n => n.User).WithMany()
.HasForeignKey(n => n.UserId).OnDelete(DeleteBehavior.Cascade);
b.Entity<Notification>().HasIndex(n => new { n.UserId, n.IsRead, n.CreatedAt });
b.Entity<Report>().HasIndex(r => r.Status);
// Dedupe ingested listings by content hash.
b.Entity<RawListing>().HasIndex(r => r.ContentHash);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class AdminBanReports : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BanReason",
table: "Users",
type: "character varying(300)",
maxLength: 300,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsBanned",
table: "Users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "Reports",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TargetType = table.Column<int>(type: "integer", nullable: false),
TargetId = table.Column<int>(type: "integer", nullable: false),
TargetLabel = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: true),
Reason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
ReporterUserId = table.Column<int>(type: "integer", nullable: true),
ReporterVisitorId = table.Column<string>(type: "character varying(36)", maxLength: 36, nullable: true),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Reports", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Reports_Status",
table: "Reports",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Reports");
migrationBuilder.DropColumn(
name: "BanReason",
table: "Users");
migrationBuilder.DropColumn(
name: "IsBanned",
table: "Users");
}
}
}
@@ -522,6 +522,49 @@ namespace JobsMedical.Web.Migrations
b.ToTable("RawListings");
});
modelBuilder.Entity("JobsMedical.Web.Models.Report", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int?>("ReporterUserId")
.HasColumnType("integer");
b.Property<string>("ReporterVisitorId")
.HasMaxLength(36)
.HasColumnType("character varying(36)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("TargetId")
.HasColumnType("integer");
b.Property<string>("TargetLabel")
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("TargetType")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("Reports");
});
modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
{
b.Property<int>("Id")
@@ -630,6 +673,10 @@ namespace JobsMedical.Web.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("BanReason")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -637,6 +684,9 @@ namespace JobsMedical.Web.Migrations
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<bool>("IsBanned")
.HasColumnType("boolean");
b.Property<bool>("IsPhoneVerified")
.HasColumnType("boolean");
+3
View File
@@ -93,3 +93,6 @@ public enum IngestionMode
Manual = 0, // همه‌چیز به صف بررسی می‌رود؛ ادمین تأیید می‌کند
Automatic = 1 // موارد تأییدشده (طبق آستانه/هوش مصنوعی) خودکار منتشر می‌شوند
}
public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 }
public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 }
+22
View File
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>A user-submitted report against a listing/facility/user (abuse, fake, wrong info).</summary>
public class Report
{
public int Id { get; set; }
public ReportTargetType TargetType { get; set; }
public int TargetId { get; set; }
[MaxLength(160)] public string? TargetLabel { get; set; } // snapshot for the admin list
[Required, MaxLength(500)]
public string Reason { get; set; } = "";
public int? ReporterUserId { get; set; }
[MaxLength(36)] public string? ReporterVisitorId { get; set; }
public ReportStatus Status { get; set; } = ReportStatus.Open;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+4
View File
@@ -20,6 +20,10 @@ public class User
public bool IsPhoneVerified { get; set; }
/// <summary>Banned users can't log in or post (set by admin).</summary>
public bool IsBanned { get; set; }
[MaxLength(300)] public string? BanReason { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
@@ -62,6 +62,11 @@ public class LoginModel : PageModel
// Find or create the user. The configured admin phone is granted the Admin role.
var user = await _db.Users.FirstOrDefaultAsync(u => u.Phone == phone);
if (user is { IsBanned: true })
{
Error = "حساب شما مسدود شده است." + (string.IsNullOrWhiteSpace(user.BanReason) ? "" : $" ({user.BanReason})");
return Page();
}
var isAdmin = phone == OtpService.Normalize(_config["Auth:AdminPhone"] ?? "");
var isEmployer = string.Equals(AccountType, "employer", StringComparison.OrdinalIgnoreCase);
if (user is null)
@@ -0,0 +1,40 @@
@page
@model JobsMedical.Web.Pages.Admin.BroadcastModel
@{
ViewData["Title"] = "ارسال اعلان همگانی";
}
<div class="page-head">
<div class="container">
<h1>ارسال اعلان همگانی</h1>
<p class="muted"><a asp-page="/Admin/Overview">← داشبورد</a></p>
</div>
</div>
<div class="container section" style="max-width:560px;">
@if (Model.Result is not null) { <div class="alert alert-success">✓ @Model.Result</div> }
<form method="post" class="card card-pad">
<div class="filter-group">
<label>مخاطب</label>
<select name="Audience">
<option value="all" selected="@(Model.Audience == "all")">همه کاربران</option>
<option value="staff" selected="@(Model.Audience == "staff")">فقط کادر درمان</option>
<option value="employers" selected="@(Model.Audience == "employers")">فقط کارفرمایان</option>
</select>
</div>
<div class="filter-group">
<label>عنوان</label>
<input type="text" name="Title" value="@Model.Title" placeholder="مثلاً: شیفت‌های جدید این هفته" />
</div>
<div class="filter-group">
<label>متن</label>
<textarea name="Body" rows="3">@Model.Body</textarea>
</div>
<div class="filter-group">
<label>لینک (اختیاری)</label>
<input type="text" name="Url" value="@Model.Url" dir="ltr" placeholder="/Shifts" />
</div>
<button type="submit" class="btn btn-accent btn-block btn-lg">ارسال اعلان</button>
<p class="muted" style="font-size:12px; margin-bottom:0;">اعلان در زنگوله‌ی کاربران ثبت می‌شود و اگر پوش فعال باشد به‌صورت اعلان مرورگری هم ارسال می‌شود.</p>
</form>
</div>
@@ -0,0 +1,48 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Admin;
[Authorize(Roles = "Admin")]
public class BroadcastModel : PageModel
{
private readonly AppDbContext _db;
private readonly NotificationService _notify;
public BroadcastModel(AppDbContext db, NotificationService notify)
{
_db = db;
_notify = notify;
}
[BindProperty] public string Title { get; set; } = "";
[BindProperty] public string? Body { get; set; }
[BindProperty] public string? Url { get; set; }
[BindProperty] public string Audience { get; set; } = "all"; // all | staff | employers
[TempData] public string? Result { get; set; }
public void OnGet() { }
public async Task<IActionResult> OnPostAsync()
{
if (string.IsNullOrWhiteSpace(Title)) { Result = "عنوان لازم است."; return Page(); }
IQueryable<User> q = _db.Users.Where(u => !u.IsBanned);
q = Audience switch
{
"staff" => q.Where(u => u.Role == UserRole.Doctor),
"employers" => q.Where(u => u.Role == UserRole.FacilityAdmin),
_ => q,
};
var ids = await q.Select(u => u.Id).ToListAsync();
await _notify.BroadcastAsync(ids, Title.Trim(), Body?.Trim(), string.IsNullOrWhiteSpace(Url) ? "/" : Url.Trim());
Result = $"اعلان برای {ids.Count} کاربر ارسال شد (در‌اپ + پوش در صورت فعال‌بودن).";
return RedirectToPage();
}
}
+6 -2
View File
@@ -11,8 +11,12 @@
آگهی‌های جمع‌آوری‌شده از منابع را بررسی، ساختارمند و منتشر کن.
(@JalaliDate.ToPersianDigits(Model.Queue.Count.ToString()) در صف،
@JalaliDate.ToPersianDigits(Model.Flagged.Count.ToString()) پرچم‌خورده)
· <a asp-page="/Admin/Facilities">تأیید مراکز درمانی</a>
· <a asp-page="/Admin/Settings">تنظیمات جمع‌آوری و AI</a>
· <a asp-page="/Admin/Overview">داشبورد</a>
· <a asp-page="/Admin/Users">کاربران</a>
· <a asp-page="/Admin/Facilities">مراکز</a>
· <a asp-page="/Admin/Reports">گزارش‌ها</a>
· <a asp-page="/Admin/Broadcast">ارسال اعلان</a>
· <a asp-page="/Admin/Settings">تنظیمات</a>
</p>
</div>
</div>
@@ -0,0 +1,34 @@
@page
@model JobsMedical.Web.Pages.Admin.OverviewModel
@{
ViewData["Title"] = "داشبورد مدیریت";
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
}
<div class="page-head">
<div class="container">
<h1>داشبورد مدیریت</h1>
<p class="muted">
<a asp-page="/Admin/Index">صف آگهی‌ها</a> ·
<a asp-page="/Admin/Users">کاربران</a> ·
<a asp-page="/Admin/Facilities">مراکز</a> ·
<a asp-page="/Admin/Reports">گزارش‌ها</a> ·
<a asp-page="/Admin/Broadcast">ارسال اعلان</a> ·
<a asp-page="/Admin/Settings">تنظیمات</a>
</p>
</div>
</div>
<div class="container section">
<div class="grid grid-4">
<div class="card card-pad"><div class="stat-pill" style="background:none;padding:0;"><span class="n" style="color:var(--primary-dark);font-size:26px;">@P(Model.Users)</span><span class="l">کاربر (@P(Model.Staff) کادر / @P(Model.Employers) کارفرما)</span></div></div>
<div class="card card-pad"><span style="font-size:26px;font-weight:900;color:var(--primary-dark)">@P(Model.Facilities)</span><div class="muted">مرکز (@P(Model.VerifiedFacilities) تأیید‌شده، @P(Model.PendingFacilities) در انتظار)</div></div>
<div class="card card-pad"><span style="font-size:26px;font-weight:900;color:var(--primary-dark)">@P(Model.OpenShifts)</span><div class="muted">شیفت باز</div></div>
<div class="card card-pad"><span style="font-size:26px;font-weight:900;color:var(--primary-dark)">@P(Model.OpenJobs)</span><div class="muted">استخدام باز</div></div>
<div class="card card-pad"><span style="font-size:26px;font-weight:900;color:var(--accent)">@P(Model.Applies)</span><div class="muted">اعلام تمایل</div></div>
<div class="card card-pad"><span style="font-size:26px;font-weight:900;color:var(--accent)">@P(Model.PushSubs)</span><div class="muted">اشتراک اعلان</div></div>
<div class="card card-pad"><span style="font-size:26px;font-weight:900;">@P(Model.QueueNew + Model.QueueFlagged)</span><div class="muted">در صف بررسی (@P(Model.QueueFlagged) پرچم‌خورده) · <a asp-page="/Admin/Index">باز کن</a></div></div>
<div class="card card-pad"><span style="font-size:26px;font-weight:900;color:@(Model.OpenReports>0?"var(--danger)":"inherit")">@P(Model.OpenReports)</span><div class="muted">گزارش باز · <a asp-page="/Admin/Reports">رسیدگی</a></div></div>
<div class="card card-pad"><span style="font-size:26px;font-weight:900;color:@(Model.Banned>0?"var(--danger)":"inherit")">@P(Model.Banned)</span><div class="muted">کاربر مسدود · <a asp-page="/Admin/Users">مدیریت</a></div></div>
</div>
</div>
@@ -0,0 +1,38 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Admin;
[Authorize(Roles = "Admin")]
public class OverviewModel : PageModel
{
private readonly AppDbContext _db;
public OverviewModel(AppDbContext db) => _db = db;
public int Users, Employers, Staff, Banned;
public int Facilities, VerifiedFacilities, PendingFacilities;
public int OpenShifts, OpenJobs, Applies;
public int PushSubs, QueueNew, QueueFlagged, OpenReports;
public async Task OnGetAsync()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
Users = await _db.Users.CountAsync();
Employers = await _db.Users.CountAsync(u => u.Role == UserRole.FacilityAdmin);
Staff = await _db.Users.CountAsync(u => u.Role == UserRole.Doctor);
Banned = await _db.Users.CountAsync(u => u.IsBanned);
Facilities = await _db.Facilities.CountAsync();
VerifiedFacilities = await _db.Facilities.CountAsync(f => f.IsVerified);
PendingFacilities = Facilities - VerifiedFacilities;
OpenShifts = await _db.Shifts.CountAsync(s => s.Status == ShiftStatus.Open && s.Date >= today);
OpenJobs = await _db.JobOpenings.CountAsync(j => j.Status == ShiftStatus.Open);
Applies = await _db.InterestEvents.CountAsync(e => e.EventType == InterestEventType.Apply);
PushSubs = await _db.WebPushSubscriptions.CountAsync();
QueueNew = await _db.RawListings.CountAsync(r => r.Status == RawListingStatus.New);
QueueFlagged = await _db.RawListings.CountAsync(r => r.Status == RawListingStatus.Flagged);
OpenReports = await _db.Reports.CountAsync(r => r.Status == ReportStatus.Open);
}
}
@@ -0,0 +1,50 @@
@page
@model JobsMedical.Web.Pages.Admin.ReportsModel
@{
ViewData["Title"] = "گزارش‌های تخلف";
string TypeLabel(ReportTargetType t) => t switch
{
ReportTargetType.Shift => "شیفت", ReportTargetType.Job => "استخدام",
ReportTargetType.Facility => "مرکز", _ => "کاربر"
};
string StatusLabel(ReportStatus s) => s switch
{
ReportStatus.Open => "باز", ReportStatus.Resolved => "رسیدگی‌شده", _ => "رد‌شده"
};
}
<div class="page-head">
<div class="container">
<h1>گزارش‌های تخلف</h1>
<p class="muted"><a asp-page="/Admin/Overview">← داشبورد</a> · <a asp-page="/Admin/Users">کاربران</a></p>
</div>
</div>
<div class="container section">
@if (Model.Reports.Count == 0)
{
<div class="card empty-state">گزارشی ثبت نشده است.</div>
}
else
{
foreach (var r in Model.Reports)
{
<div class="card card-pad" style="margin-bottom:10px; @(r.Status == ReportStatus.Open ? "" : "opacity:.6;")">
<div class="row" style="display:flex; justify-content:space-between; align-items:center; gap:8px; flex-wrap:wrap;">
<strong>@TypeLabel(r.TargetType): @(r.TargetLabel ?? ("#" + r.TargetId))</strong>
<span class="badge @(r.Status == ReportStatus.Open ? "badge-day" : "badge-type")">@StatusLabel(r.Status)</span>
</div>
<p style="margin:8px 0;">«@r.Reason»</p>
<div class="muted" style="font-size:12px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(r.CreatedAt)) · گزارش‌دهنده: @(r.ReporterUserId is not null ? "کاربر #" + r.ReporterUserId : "مهمان")</div>
<div style="display:flex; gap:8px; margin-top:10px;">
<a class="btn btn-outline" style="padding:6px 12px;" href="@JobsMedical.Web.Pages.Admin.ReportsModel.TargetUrl(r)" target="_blank">مشاهده مورد</a>
@if (r.Status == ReportStatus.Open)
{
<form method="post"><button asp-page-handler="Resolve" asp-route-id="@r.Id" class="btn btn-outline" style="padding:6px 12px;">رسیدگی شد</button></form>
<form method="post"><button asp-page-handler="Dismiss" asp-route-id="@r.Id" class="btn btn-outline" style="padding:6px 12px;">رد گزارش</button></form>
}
</div>
</div>
}
}
</div>
@@ -0,0 +1,42 @@
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.Admin;
[Authorize(Roles = "Admin")]
public class ReportsModel : PageModel
{
private readonly AppDbContext _db;
public ReportsModel(AppDbContext db) => _db = db;
public List<Report> Reports { get; private set; } = new();
public async Task OnGetAsync() =>
Reports = await _db.Reports
.OrderBy(r => r.Status).ThenByDescending(r => r.CreatedAt)
.Take(200).ToListAsync();
public async Task<IActionResult> OnPostResolveAsync(int id) => await SetStatus(id, ReportStatus.Resolved);
public async Task<IActionResult> OnPostDismissAsync(int id) => await SetStatus(id, ReportStatus.Dismissed);
private async Task<IActionResult> SetStatus(int id, ReportStatus st)
{
var r = await _db.Reports.FindAsync(id);
if (r is null) return NotFound();
r.Status = st;
await _db.SaveChangesAsync();
return RedirectToPage();
}
public static string TargetUrl(Report r) => r.TargetType switch
{
ReportTargetType.Shift => $"/Shifts/Details/{r.TargetId}",
ReportTargetType.Job => $"/Jobs/Details/{r.TargetId}",
ReportTargetType.Facility => "/Admin/Facilities",
_ => "/Admin/Users",
};
}
@@ -12,6 +12,14 @@
</div>
<div class="container section" style="max-width:680px;">
@if (Model.SmsTest is not null) { <div class="alert alert-success">@Model.SmsTest</div> }
<form method="post" class="card card-pad" style="margin-bottom:14px; display:flex; gap:8px; align-items:end; flex-wrap:wrap;">
<div class="filter-group" style="margin:0; flex:1; min-width:160px;">
<label>ارسال پیامک آزمایشی به</label>
<input type="tel" name="TestPhone" dir="ltr" placeholder="۰۹۱۲ ..." />
</div>
<button type="submit" asp-page-handler="TestSms" class="btn btn-outline">ارسال آزمایشی</button>
</form>
@if (Model.Saved is not null)
{
<div class="alert alert-success">✓ @Model.Saved</div>
@@ -1,4 +1,5 @@
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using JobsMedical.Web.Services.Scraping;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -10,7 +11,12 @@ namespace JobsMedical.Web.Pages.Admin;
public class SettingsModel : PageModel
{
private readonly SettingsService _settings;
public SettingsModel(SettingsService settings) => _settings = settings;
private readonly ISmsSender _sms;
public SettingsModel(SettingsService settings, ISmsSender sms)
{
_settings = settings;
_sms = sms;
}
[BindProperty] public IngestionMode Mode { get; set; }
[BindProperty] public int AutoPublishMinConfidence { get; set; }
@@ -41,7 +47,9 @@ public class SettingsModel : PageModel
[BindProperty] public string? VapidPublicKey { get; set; }
[BindProperty] public string? VapidPrivateKey { get; set; }
[BindProperty] public string? VapidSubject { get; set; }
[BindProperty] public string? TestPhone { get; set; }
[TempData] public string? Saved { get; set; }
[TempData] public string? SmsTest { get; set; }
public async Task OnGetAsync()
{
@@ -112,4 +120,19 @@ public class SettingsModel : PageModel
Saved = "تنظیمات ذخیره شد.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostTestSmsAsync()
{
var s = await _settings.GetAsync();
var phone = OtpService.Normalize(TestPhone ?? "");
if (phone.Length < 10) { SmsTest = "شماره معتبر وارد کنید."; return RedirectToPage(); }
if (!s.SmsEnabled) { SmsTest = "ابتدا SMS را فعال و ذخیره کنید."; return RedirectToPage(); }
try
{
var ok = await _sms.SendOtpAsync(phone, Random.Shared.Next(10000, 100000).ToString(), s);
SmsTest = ok ? $"پیامک آزمایشی به {phone} ارسال شد." : "ارسال ناموفق بود (پاسخ منفی از سرویس).";
}
catch (Exception ex) { SmsTest = "خطا در ارسال: " + ex.Message; }
return RedirectToPage();
}
}
@@ -0,0 +1,69 @@
@page
@model JobsMedical.Web.Pages.Admin.UsersModel
@{
ViewData["Title"] = "مدیریت کاربران";
string RoleLabel(UserRole r) => r switch { UserRole.Admin => "مدیر", UserRole.FacilityAdmin => "کارفرما", _ => "کادر درمان" };
}
<div class="page-head">
<div class="container">
<h1>مدیریت کاربران</h1>
<p class="muted">
<a asp-page="/Admin/Index">صف آگهی‌ها</a> ·
<a asp-page="/Admin/Overview">داشبورد</a> ·
<a asp-page="/Admin/Reports">گزارش‌ها</a> ·
<a asp-page="/Admin/Broadcast">ارسال اعلان</a>
</p>
</div>
</div>
<div class="container section">
@if (TempData["err"] is string e) { <div class="alert" style="background:#fdeaea; color:var(--danger);">@e</div> }
<form method="get" class="card card-pad" style="display:flex; gap:8px; align-items:end; flex-wrap:wrap; margin-bottom:14px;">
<div class="filter-group" style="margin:0; flex:1; min-width:160px;">
<label>جستجو (شماره/نام)</label>
<input type="text" name="Q" value="@Model.Q" dir="ltr" />
</div>
<div class="filter-group" style="margin:0;">
<label>نقش</label>
<select name="RoleFilter">
<option value="">همه</option>
<option value="0" selected="@(Model.RoleFilter == UserRole.Doctor)">کادر درمان</option>
<option value="2" selected="@(Model.RoleFilter == UserRole.FacilityAdmin)">کارفرما</option>
<option value="1" selected="@(Model.RoleFilter == UserRole.Admin)">مدیر</option>
</select>
</div>
<button type="submit" class="btn btn-outline">فیلتر</button>
</form>
@foreach (var row in Model.Users)
{
var u = row.User;
<div class="card card-pad" style="margin-bottom:10px; display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
<div>
<strong dir="ltr">@JalaliDate.ToPersianDigits(u.Phone)</strong>
@if (!string.IsNullOrEmpty(u.FullName)) { <text> — @u.FullName</text> }
<span class="badge badge-type">@RoleLabel(u.Role)</span>
@if (row.Facilities > 0) { <span class="badge badge-job">@JalaliDate.ToPersianDigits(row.Facilities.ToString()) مرکز</span> }
@if (u.IsBanned) { <span class="badge" style="background:#fdeaea;color:var(--danger);">مسدود</span> }
<div class="muted" style="font-size:12px;">عضویت: @JalaliDate.ToLongDate(DateOnly.FromDateTime(u.CreatedAt))@(u.IsBanned && u.BanReason != null ? " — دلیل مسدودی: " + u.BanReason : "")</div>
</div>
@if (u.Role != UserRole.Admin)
{
@if (u.IsBanned)
{
<form method="post"><button asp-page-handler="Unban" asp-route-id="@u.Id" class="btn btn-outline">رفع مسدودی</button></form>
}
else
{
<form method="post" style="display:flex; gap:6px;" onsubmit="return confirm('این کاربر مسدود شود؟');">
<input type="text" name="reason" placeholder="دلیل (اختیاری)" style="width:150px;" />
<button asp-page-handler="Ban" asp-route-id="@u.Id" class="btn btn-outline" style="color:var(--danger); border-color:var(--danger);">مسدود کردن</button>
</form>
}
}
</div>
}
@if (Model.Users.Count == 0) { <div class="card empty-state">کاربری یافت نشد.</div> }
</div>
@@ -0,0 +1,59 @@
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.Admin;
[Authorize(Roles = "Admin")]
public class UsersModel : PageModel
{
private readonly AppDbContext _db;
public UsersModel(AppDbContext db) => _db = db;
public record Row(User User, int Facilities);
public List<Row> Users { get; private set; } = new();
[BindProperty(SupportsGet = true)] public string? Q { get; set; }
[BindProperty(SupportsGet = true)] public UserRole? RoleFilter { get; set; }
public async Task OnGetAsync()
{
IQueryable<User> q = _db.Users;
if (!string.IsNullOrWhiteSpace(Q))
{
var s = Q.Trim();
q = q.Where(u => u.Phone.Contains(s) || (u.FullName != null && u.FullName.Contains(s)));
}
if (RoleFilter is not null) q = q.Where(u => u.Role == RoleFilter);
var users = await q.OrderByDescending(u => u.CreatedAt).Take(300).ToListAsync();
var ids = users.Select(u => u.Id).ToList();
var facCounts = await _db.Facilities.Where(f => f.OwnerUserId != null && ids.Contains(f.OwnerUserId.Value))
.GroupBy(f => f.OwnerUserId!.Value).Select(g => new { g.Key, C = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.C);
Users = users.Select(u => new Row(u, facCounts.GetValueOrDefault(u.Id))).ToList();
}
public async Task<IActionResult> OnPostBanAsync(int id, string? reason)
{
var u = await _db.Users.FindAsync(id);
if (u is null) return NotFound();
if (u.Role == UserRole.Admin) { TempData["err"] = "نمی‌توان مدیر را مسدود کرد."; return RedirectToPage(new { Q, RoleFilter }); }
u.IsBanned = true;
u.BanReason = string.IsNullOrWhiteSpace(reason) ? "نقض قوانین" : reason.Trim();
await _db.SaveChangesAsync();
return RedirectToPage(new { Q, RoleFilter });
}
public async Task<IActionResult> OnPostUnbanAsync(int id)
{
var u = await _db.Users.FindAsync(id);
if (u is null) return NotFound();
u.IsBanned = false; u.BanReason = null;
await _db.SaveChangesAsync();
return RedirectToPage(new { Q, RoleFilter });
}
}
@@ -89,6 +89,24 @@
<button type="submit" asp-page-handler="Dismiss" asp-route-id="@j.Id" class="btn btn-outline btn-block">✕ علاقه‌مند نیستم</button>
</form>
</div>
@if (Model.Reported)
{
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
}
else
{
<details style="margin-top:10px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">گزارش تخلف یا اطلاعات نادرست</summary>
<form method="post" action="/report" style="margin-top:8px;">
<input type="hidden" name="targetType" value="Job" />
<input type="hidden" name="targetId" value="@j.Id" />
<input type="hidden" name="label" value="@j.Title" />
<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>
</aside>
</div>
@@ -21,11 +21,13 @@ public class DetailsModel : PageModel
public JobOpening? Job { get; private set; }
public bool ShowContact { get; private set; }
public bool Saved { get; private set; }
public bool Reported { get; private set; }
public async Task<IActionResult> OnGetAsync(int id)
{
await LoadAsync(id);
if (Job is null) return NotFound();
Reported = Request.Query["reported"] == "1";
await _interest.LogJobAsync(InterestEventType.View, id);
return Page();
}
@@ -50,7 +50,7 @@
{
@if (User.IsInRole("Admin"))
{
<a asp-page="/Admin/Index" style="margin-inline-end:14px; font-weight:600;">پنل مدیریت</a>
<a asp-page="/Admin/Overview" style="margin-inline-end:14px; font-weight:600;">پنل مدیریت</a>
}
@if (User.IsInRole("FacilityAdmin"))
{
@@ -108,6 +108,24 @@
class="btn btn-outline btn-block">✕ علاقه‌مند نیستم</button>
</form>
</div>
@if (Model.Reported)
{
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
}
else
{
<details style="margin-top:10px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">گزارش تخلف یا اطلاعات نادرست</summary>
<form method="post" action="/report" style="margin-top:8px;">
<input type="hidden" name="targetType" value="Shift" />
<input type="hidden" name="targetId" value="@s.Id" />
<input type="hidden" name="label" value="@(s.Role?.Name) — @s.Facility?.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 class="card card-pad" style="margin-top:16px;">
@@ -24,11 +24,13 @@ public class DetailsModel : PageModel
// Set after the visitor taps "interested" — reveals the facility contact (handoff model).
public bool ShowContact { get; private set; }
public bool Saved { get; private set; }
public bool Reported { get; private set; }
public async Task<IActionResult> OnGetAsync(int id)
{
await LoadAsync(id);
if (Shift is null) return NotFound();
Reported = Request.Query["reported"] == "1";
await _interest.LogAsync(InterestEventType.View, id); // behavioral signal for recommendations
return Page();
}
+21
View File
@@ -5,6 +5,7 @@ using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
@@ -157,6 +158,26 @@ app.MapPost("/push/subscribe", async (PushSubscriptionDto dto, AppDbContext db,
return Results.Ok();
});
// User-submitted report against a listing (abuse/fake/wrong info).
app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext vc,
[FromForm] string targetType, [FromForm] int targetId, [FromForm] string reason,
[FromForm] string? label, [FromForm] string? returnUrl) =>
{
if (!string.IsNullOrWhiteSpace(reason) && Enum.TryParse<ReportTargetType>(targetType, true, out var tt))
{
int? uid = ctx.User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier) is { } c
&& int.TryParse(c.Value, out var n) ? n : null;
db.Reports.Add(new Report
{
TargetType = tt, TargetId = targetId, TargetLabel = label,
Reason = reason.Trim()[..Math.Min(reason.Trim().Length, 500)],
ReporterUserId = uid, ReporterVisitorId = vc.VisitorId,
});
await db.SaveChangesAsync();
}
return Results.Redirect((string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl) + "?reported=1");
}).DisableAntiforgery();
app.MapGet("/sw.js", () => Results.Content("""
const CACHE = 'hamkadr-v1';
self.addEventListener('install', e => { self.skipWaiting(); e.waitUntil(caches.open(CACHE).then(c => c.addAll(['/']))); });
@@ -33,6 +33,10 @@ public class NotificationService
await _db.Notifications.Where(n => n.UserId == userId && !n.IsRead)
.ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true));
/// <summary>Admin broadcast: notify a set of users (in-app + push) with a custom message.</summary>
public Task BroadcastAsync(IReadOnlyCollection<int> userIds, string title, string? body, string? url)
=> AddAsync(userIds.ToList(), title, body, url ?? "/");
public async Task NotifyNewShiftAsync(int shiftId)
{
var s = await _db.Shifts.Include(x => x.Facility).Include(x => x.Role)