From eae38373b92b51f0e4cb737b260ee86ef3e36b67 Mon Sep 17 00:00:00 2001
From: "soroush.asadi"
Date: Thu, 4 Jun 2026 13:19:20 +0330
Subject: [PATCH] Admin suite: monitoring dashboard, user management/ban,
broadcast, reports, SMS test
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- /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
---
src/JobsMedical.Web/Data/AppDbContext.cs | 2 +
...20260604094613_AdminBanReports.Designer.cs | 1049 +++++++++++++++++
.../20260604094613_AdminBanReports.cs | 70 ++
.../Migrations/AppDbContextModelSnapshot.cs | 50 +
src/JobsMedical.Web/Models/Enums.cs | 3 +
src/JobsMedical.Web/Models/Report.cs | 22 +
src/JobsMedical.Web/Models/User.cs | 4 +
.../Pages/Account/Login.cshtml.cs | 5 +
.../Pages/Admin/Broadcast.cshtml | 40 +
.../Pages/Admin/Broadcast.cshtml.cs | 48 +
src/JobsMedical.Web/Pages/Admin/Index.cshtml | 8 +-
.../Pages/Admin/Overview.cshtml | 34 +
.../Pages/Admin/Overview.cshtml.cs | 38 +
.../Pages/Admin/Reports.cshtml | 50 +
.../Pages/Admin/Reports.cshtml.cs | 42 +
.../Pages/Admin/Settings.cshtml | 8 +
.../Pages/Admin/Settings.cshtml.cs | 25 +-
src/JobsMedical.Web/Pages/Admin/Users.cshtml | 69 ++
.../Pages/Admin/Users.cshtml.cs | 59 +
src/JobsMedical.Web/Pages/Jobs/Details.cshtml | 18 +
.../Pages/Jobs/Details.cshtml.cs | 2 +
.../Pages/Shared/_Layout.cshtml | 2 +-
.../Pages/Shifts/Details.cshtml | 18 +
.../Pages/Shifts/Details.cshtml.cs | 2 +
src/JobsMedical.Web/Program.cs | 21 +
.../Services/NotificationService.cs | 4 +
26 files changed, 1689 insertions(+), 4 deletions(-)
create mode 100644 src/JobsMedical.Web/Migrations/20260604094613_AdminBanReports.Designer.cs
create mode 100644 src/JobsMedical.Web/Migrations/20260604094613_AdminBanReports.cs
create mode 100644 src/JobsMedical.Web/Models/Report.cs
create mode 100644 src/JobsMedical.Web/Pages/Admin/Broadcast.cshtml
create mode 100644 src/JobsMedical.Web/Pages/Admin/Broadcast.cshtml.cs
create mode 100644 src/JobsMedical.Web/Pages/Admin/Overview.cshtml
create mode 100644 src/JobsMedical.Web/Pages/Admin/Overview.cshtml.cs
create mode 100644 src/JobsMedical.Web/Pages/Admin/Reports.cshtml
create mode 100644 src/JobsMedical.Web/Pages/Admin/Reports.cshtml.cs
create mode 100644 src/JobsMedical.Web/Pages/Admin/Users.cshtml
create mode 100644 src/JobsMedical.Web/Pages/Admin/Users.cshtml.cs
diff --git a/src/JobsMedical.Web/Data/AppDbContext.cs b/src/JobsMedical.Web/Data/AppDbContext.cs
index ff4f991..e10d3fb 100644
--- a/src/JobsMedical.Web/Data/AppDbContext.cs
+++ b/src/JobsMedical.Web/Data/AppDbContext.cs
@@ -23,6 +23,7 @@ public class AppDbContext : DbContext
public DbSet AppSettings => Set();
public DbSet WebPushSubscriptions => Set();
public DbSet Notifications => Set();
+ public DbSet Reports => Set();
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().HasIndex(n => new { n.UserId, n.IsRead, n.CreatedAt });
+ b.Entity().HasIndex(r => r.Status);
// Dedupe ingested listings by content hash.
b.Entity().HasIndex(r => r.ContentHash);
diff --git a/src/JobsMedical.Web/Migrations/20260604094613_AdminBanReports.Designer.cs b/src/JobsMedical.Web/Migrations/20260604094613_AdminBanReports.Designer.cs
new file mode 100644
index 0000000..4637743
--- /dev/null
+++ b/src/JobsMedical.Web/Migrations/20260604094613_AdminBanReports.Designer.cs
@@ -0,0 +1,1049 @@
+//
+using System;
+using JobsMedical.Web.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace JobsMedical.Web.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260604094613_AdminBanReports")]
+ partial class AdminBanReports
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("JobsMedical.Web.Models.AppSetting", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AiApiKey")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("AiAutoApprove")
+ .HasColumnType("boolean");
+
+ b.Property("AiEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("AiEndpoint")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("AiModel")
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("AiSystemPrompt")
+ .IsRequired()
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)");
+
+ b.Property("AutoIngestEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("AutoPublishMinConfidence")
+ .HasColumnType("integer");
+
+ b.Property("BaleBotToken")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("BaleEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("DivarCity")
+ .HasMaxLength(60)
+ .HasColumnType("character varying(60)");
+
+ b.Property("DivarEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("DivarQueries")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("IngestIntervalMinutes")
+ .HasColumnType("integer");
+
+ b.Property("MedjobsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("MedjobsMaxAds")
+ .HasColumnType("integer");
+
+ b.Property("Mode")
+ .HasColumnType("integer");
+
+ b.Property("NeshanMapKey")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("PushEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("SmsApiKey")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("SmsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("SmsSender")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("SmsTemplate")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("TelegramChannels")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("TelegramEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("VapidPrivateKey")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("VapidPublicKey")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("VapidSubject")
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.HasKey("Id");
+
+ b.ToTable("AppSettings");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Application", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DoctorId")
+ .HasColumnType("integer");
+
+ b.Property("Message")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("ShiftId")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DoctorId");
+
+ b.HasIndex("ShiftId", "DoctorId")
+ .IsUnique();
+
+ b.ToTable("Applications");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.City", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Province")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Cities");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CityId")
+ .HasColumnType("integer");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CityId");
+
+ b.ToTable("Districts");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.DoctorProfile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Bio")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("CityId")
+ .HasColumnType("integer");
+
+ b.Property("IsVerified")
+ .HasColumnType("boolean");
+
+ b.Property("LicenseNo")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("RoleId")
+ .HasColumnType("integer");
+
+ b.Property("Specialty")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.Property("YearsExperience")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CityId");
+
+ b.HasIndex("RoleId");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("DoctorProfiles");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Facility", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Address")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("BaleId")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("CityId")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DistrictId")
+ .HasColumnType("integer");
+
+ b.Property("IsVerified")
+ .HasColumnType("boolean");
+
+ b.Property("Lat")
+ .HasColumnType("double precision");
+
+ b.Property("Lng")
+ .HasColumnType("double precision");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("integer");
+
+ b.Property("Phone")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CityId");
+
+ b.HasIndex("DistrictId");
+
+ b.HasIndex("OwnerUserId");
+
+ b.ToTable("Facilities");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EventType")
+ .HasColumnType("integer");
+
+ b.Property("JobOpeningId")
+ .HasColumnType("integer");
+
+ b.Property("ShiftId")
+ .HasColumnType("integer");
+
+ b.Property("VisitorId")
+ .IsRequired()
+ .HasColumnType("character varying(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("JobOpeningId");
+
+ b.HasIndex("ShiftId");
+
+ b.HasIndex("VisitorId", "CreatedAt");
+
+ b.ToTable("InterestEvents");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("EmploymentType")
+ .HasColumnType("integer");
+
+ b.Property("FacilityId")
+ .HasColumnType("integer");
+
+ b.Property("GenderRequirement")
+ .HasColumnType("integer");
+
+ b.Property("Requirements")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("RoleId")
+ .HasColumnType("integer");
+
+ b.Property("SalaryMax")
+ .HasColumnType("bigint");
+
+ b.Property("SalaryMin")
+ .HasColumnType("bigint");
+
+ b.Property("Source")
+ .HasColumnType("integer");
+
+ b.Property("SourceUrl")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FacilityId");
+
+ b.HasIndex("RoleId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("JobOpenings");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Notification", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Body")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsRead")
+ .HasColumnType("boolean");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Url")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "IsRead", "CreatedAt");
+
+ b.ToTable("Notifications");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.RawListing", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Confidence")
+ .HasColumnType("integer");
+
+ b.Property("ContentHash")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("FetchedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LinkedShiftId")
+ .HasColumnType("integer");
+
+ b.Property("ParsedJson")
+ .HasColumnType("text");
+
+ b.Property("RawText")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SourceChannel")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("SourceUrl")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("ValidationNotes")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ContentHash");
+
+ b.HasIndex("LinkedShiftId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("RawListings");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Report", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("ReporterUserId")
+ .HasColumnType("integer");
+
+ b.Property("ReporterVisitorId")
+ .HasMaxLength(36)
+ .HasColumnType("character varying(36)");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("TargetId")
+ .HasColumnType("integer");
+
+ b.Property("TargetLabel")
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)");
+
+ b.Property("TargetType")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Status");
+
+ b.ToTable("Reports");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Roles");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Date")
+ .HasColumnType("date");
+
+ b.Property("Description")
+ .HasMaxLength(1500)
+ .HasColumnType("character varying(1500)");
+
+ b.Property("EndTime")
+ .HasColumnType("time without time zone");
+
+ b.Property("FacilityId")
+ .HasColumnType("integer");
+
+ b.Property("GenderRequirement")
+ .HasColumnType("integer");
+
+ b.Property("PayAmount")
+ .HasColumnType("bigint");
+
+ b.Property("PayType")
+ .HasColumnType("integer");
+
+ b.Property("RoleId")
+ .HasColumnType("integer");
+
+ b.Property("SharePercent")
+ .HasColumnType("integer");
+
+ b.Property("ShiftType")
+ .HasColumnType("integer");
+
+ b.Property("Source")
+ .HasColumnType("integer");
+
+ b.Property("SourceUrl")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("SpecialtyRequired")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("StartTime")
+ .HasColumnType("time without time zone");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FacilityId");
+
+ b.HasIndex("RoleId");
+
+ b.HasIndex("Date", "Status");
+
+ b.ToTable("Shifts");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("BanReason")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FullName")
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("IsBanned")
+ .HasColumnType("boolean");
+
+ b.Property("IsPhoneVerified")
+ .HasColumnType("boolean");
+
+ b.Property("Phone")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Role")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Phone")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CityId")
+ .HasColumnType("integer");
+
+ b.Property("Gender")
+ .HasColumnType("integer");
+
+ b.Property("MinPay")
+ .HasColumnType("bigint");
+
+ b.Property("PreferredShiftType")
+ .HasColumnType("integer");
+
+ b.Property("RoleId")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("VisitorId")
+ .IsRequired()
+ .HasColumnType("character varying(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CityId");
+
+ b.HasIndex("RoleId");
+
+ b.HasIndex("VisitorId")
+ .IsUnique();
+
+ b.ToTable("UserPreferences");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b =>
+ {
+ b.Property("Id")
+ .HasMaxLength(36)
+ .HasColumnType("character varying(36)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Visitors");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.WebPushSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Auth")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Endpoint")
+ .IsRequired()
+ .HasMaxLength(600)
+ .HasColumnType("character varying(600)");
+
+ b.Property("P256dh")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("VisitorId")
+ .HasMaxLength(36)
+ .HasColumnType("character varying(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Endpoint")
+ .IsUnique();
+
+ b.ToTable("WebPushSubscriptions");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Application", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.User", "Doctor")
+ .WithMany("Applications")
+ .HasForeignKey("DoctorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("JobsMedical.Web.Models.Shift", "Shift")
+ .WithMany("Applications")
+ .HasForeignKey("ShiftId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Doctor");
+
+ b.Navigation("Shift");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.City", "City")
+ .WithMany()
+ .HasForeignKey("CityId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("City");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.DoctorProfile", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.City", "City")
+ .WithMany()
+ .HasForeignKey("CityId");
+
+ b.HasOne("JobsMedical.Web.Models.Role", "Role")
+ .WithMany()
+ .HasForeignKey("RoleId");
+
+ b.HasOne("JobsMedical.Web.Models.User", "User")
+ .WithOne("DoctorProfile")
+ .HasForeignKey("JobsMedical.Web.Models.DoctorProfile", "UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("City");
+
+ b.Navigation("Role");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Facility", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.City", "City")
+ .WithMany("Facilities")
+ .HasForeignKey("CityId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("JobsMedical.Web.Models.District", "District")
+ .WithMany("Facilities")
+ .HasForeignKey("DistrictId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("JobsMedical.Web.Models.User", "OwnerUser")
+ .WithMany()
+ .HasForeignKey("OwnerUserId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("City");
+
+ b.Navigation("District");
+
+ b.Navigation("OwnerUser");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening")
+ .WithMany()
+ .HasForeignKey("JobOpeningId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("JobsMedical.Web.Models.Shift", "Shift")
+ .WithMany()
+ .HasForeignKey("ShiftId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("JobsMedical.Web.Models.Visitor", "Visitor")
+ .WithMany("Events")
+ .HasForeignKey("VisitorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("JobOpening");
+
+ b.Navigation("Shift");
+
+ b.Navigation("Visitor");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
+ .WithMany()
+ .HasForeignKey("FacilityId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("JobsMedical.Web.Models.Role", "Role")
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Facility");
+
+ b.Navigation("Role");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Notification", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.RawListing", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.Shift", "LinkedShift")
+ .WithMany()
+ .HasForeignKey("LinkedShiftId");
+
+ b.Navigation("LinkedShift");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
+ .WithMany("Shifts")
+ .HasForeignKey("FacilityId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("JobsMedical.Web.Models.Role", "Role")
+ .WithMany("Shifts")
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Facility");
+
+ b.Navigation("Role");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.City", "City")
+ .WithMany()
+ .HasForeignKey("CityId");
+
+ b.HasOne("JobsMedical.Web.Models.Role", "Role")
+ .WithMany()
+ .HasForeignKey("RoleId");
+
+ b.HasOne("JobsMedical.Web.Models.Visitor", "Visitor")
+ .WithOne("Preferences")
+ .HasForeignKey("JobsMedical.Web.Models.UserPreferences", "VisitorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("City");
+
+ b.Navigation("Role");
+
+ b.Navigation("Visitor");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.City", b =>
+ {
+ b.Navigation("Facilities");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
+ {
+ b.Navigation("Facilities");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Facility", b =>
+ {
+ b.Navigation("Shifts");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
+ {
+ b.Navigation("Shifts");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
+ {
+ b.Navigation("Applications");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.User", b =>
+ {
+ b.Navigation("Applications");
+
+ b.Navigation("DoctorProfile");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b =>
+ {
+ b.Navigation("Events");
+
+ b.Navigation("Preferences");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/JobsMedical.Web/Migrations/20260604094613_AdminBanReports.cs b/src/JobsMedical.Web/Migrations/20260604094613_AdminBanReports.cs
new file mode 100644
index 0000000..01127eb
--- /dev/null
+++ b/src/JobsMedical.Web/Migrations/20260604094613_AdminBanReports.cs
@@ -0,0 +1,70 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace JobsMedical.Web.Migrations
+{
+ ///
+ public partial class AdminBanReports : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "BanReason",
+ table: "Users",
+ type: "character varying(300)",
+ maxLength: 300,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "IsBanned",
+ table: "Users",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.CreateTable(
+ name: "Reports",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ TargetType = table.Column(type: "integer", nullable: false),
+ TargetId = table.Column(type: "integer", nullable: false),
+ TargetLabel = table.Column(type: "character varying(160)", maxLength: 160, nullable: true),
+ Reason = table.Column(type: "character varying(500)", maxLength: 500, nullable: false),
+ ReporterUserId = table.Column(type: "integer", nullable: true),
+ ReporterVisitorId = table.Column(type: "character varying(36)", maxLength: 36, nullable: true),
+ Status = table.Column(type: "integer", nullable: false),
+ CreatedAt = table.Column(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");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Reports");
+
+ migrationBuilder.DropColumn(
+ name: "BanReason",
+ table: "Users");
+
+ migrationBuilder.DropColumn(
+ name: "IsBanned",
+ table: "Users");
+ }
+ }
+}
diff --git a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs
index fa62ede..8acbce5 100644
--- a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs
+++ b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs
@@ -522,6 +522,49 @@ namespace JobsMedical.Web.Migrations
b.ToTable("RawListings");
});
+ modelBuilder.Entity("JobsMedical.Web.Models.Report", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("ReporterUserId")
+ .HasColumnType("integer");
+
+ b.Property("ReporterVisitorId")
+ .HasMaxLength(36)
+ .HasColumnType("character varying(36)");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("TargetId")
+ .HasColumnType("integer");
+
+ b.Property("TargetLabel")
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)");
+
+ b.Property("TargetType")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Status");
+
+ b.ToTable("Reports");
+ });
+
modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
{
b.Property("Id")
@@ -630,6 +673,10 @@ namespace JobsMedical.Web.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+ b.Property("BanReason")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
b.Property("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -637,6 +684,9 @@ namespace JobsMedical.Web.Migrations
.HasMaxLength(150)
.HasColumnType("character varying(150)");
+ b.Property("IsBanned")
+ .HasColumnType("boolean");
+
b.Property("IsPhoneVerified")
.HasColumnType("boolean");
diff --git a/src/JobsMedical.Web/Models/Enums.cs b/src/JobsMedical.Web/Models/Enums.cs
index 13f50df..db507eb 100644
--- a/src/JobsMedical.Web/Models/Enums.cs
+++ b/src/JobsMedical.Web/Models/Enums.cs
@@ -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 }
diff --git a/src/JobsMedical.Web/Models/Report.cs b/src/JobsMedical.Web/Models/Report.cs
new file mode 100644
index 0000000..77a0b24
--- /dev/null
+++ b/src/JobsMedical.Web/Models/Report.cs
@@ -0,0 +1,22 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace JobsMedical.Web.Models;
+
+/// A user-submitted report against a listing/facility/user (abuse, fake, wrong info).
+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;
+}
diff --git a/src/JobsMedical.Web/Models/User.cs b/src/JobsMedical.Web/Models/User.cs
index 2d17b9d..8329762 100644
--- a/src/JobsMedical.Web/Models/User.cs
+++ b/src/JobsMedical.Web/Models/User.cs
@@ -20,6 +20,10 @@ public class User
public bool IsPhoneVerified { get; set; }
+ /// Banned users can't log in or post (set by admin).
+ public bool IsBanned { get; set; }
+ [MaxLength(300)] public string? BanReason { get; set; }
+
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
diff --git a/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs b/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs
index 5f9bdef..5d2bcb2 100644
--- a/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs
+++ b/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs
@@ -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)
diff --git a/src/JobsMedical.Web/Pages/Admin/Broadcast.cshtml b/src/JobsMedical.Web/Pages/Admin/Broadcast.cshtml
new file mode 100644
index 0000000..19662f6
--- /dev/null
+++ b/src/JobsMedical.Web/Pages/Admin/Broadcast.cshtml
@@ -0,0 +1,40 @@
+@page
+@model JobsMedical.Web.Pages.Admin.BroadcastModel
+@{
+ ViewData["Title"] = "ارسال اعلان همگانی";
+}
+
+
+
+
+ @if (Model.Result is not null) {
✓ @Model.Result
}
+
+
diff --git a/src/JobsMedical.Web/Pages/Admin/Broadcast.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Broadcast.cshtml.cs
new file mode 100644
index 0000000..602989e
--- /dev/null
+++ b/src/JobsMedical.Web/Pages/Admin/Broadcast.cshtml.cs
@@ -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 OnPostAsync()
+ {
+ if (string.IsNullOrWhiteSpace(Title)) { Result = "عنوان لازم است."; return Page(); }
+
+ IQueryable 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();
+ }
+}
diff --git a/src/JobsMedical.Web/Pages/Admin/Index.cshtml b/src/JobsMedical.Web/Pages/Admin/Index.cshtml
index 440d9bb..01e7d31 100644
--- a/src/JobsMedical.Web/Pages/Admin/Index.cshtml
+++ b/src/JobsMedical.Web/Pages/Admin/Index.cshtml
@@ -11,8 +11,12 @@
آگهیهای جمعآوریشده از منابع را بررسی، ساختارمند و منتشر کن.
(@JalaliDate.ToPersianDigits(Model.Queue.Count.ToString()) در صف،
@JalaliDate.ToPersianDigits(Model.Flagged.Count.ToString()) پرچمخورده)
- · تأیید مراکز درمانی
- · تنظیمات جمعآوری و AI
+ · داشبورد
+ · کاربران
+ · مراکز
+ · گزارشها
+ · ارسال اعلان
+ · تنظیمات
diff --git a/src/JobsMedical.Web/Pages/Admin/Overview.cshtml b/src/JobsMedical.Web/Pages/Admin/Overview.cshtml
new file mode 100644
index 0000000..6280d55
--- /dev/null
+++ b/src/JobsMedical.Web/Pages/Admin/Overview.cshtml
@@ -0,0 +1,34 @@
+@page
+@model JobsMedical.Web.Pages.Admin.OverviewModel
+@{
+ ViewData["Title"] = "داشبورد مدیریت";
+ string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
+}
+
+
+
+
+
+
@P(Model.Users)کاربر (@P(Model.Staff) کادر / @P(Model.Employers) کارفرما)
+
@P(Model.Facilities)مرکز (@P(Model.VerifiedFacilities) تأییدشده، @P(Model.PendingFacilities) در انتظار)
+
@P(Model.OpenShifts)شیفت باز
+
@P(Model.OpenJobs)استخدام باز
+
@P(Model.Applies)اعلام تمایل
+
@P(Model.PushSubs)اشتراک اعلان
+
@P(Model.QueueNew + Model.QueueFlagged)در صف بررسی (@P(Model.QueueFlagged) پرچمخورده) ·
باز کن
+
+
+
+
diff --git a/src/JobsMedical.Web/Pages/Admin/Overview.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Overview.cshtml.cs
new file mode 100644
index 0000000..0dec552
--- /dev/null
+++ b/src/JobsMedical.Web/Pages/Admin/Overview.cshtml.cs
@@ -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);
+ }
+}
diff --git a/src/JobsMedical.Web/Pages/Admin/Reports.cshtml b/src/JobsMedical.Web/Pages/Admin/Reports.cshtml
new file mode 100644
index 0000000..69ef91c
--- /dev/null
+++ b/src/JobsMedical.Web/Pages/Admin/Reports.cshtml
@@ -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 => "رسیدگیشده", _ => "ردشده"
+ };
+}
+
+
+
+
+ @if (Model.Reports.Count == 0)
+ {
+
گزارشی ثبت نشده است.
+ }
+ else
+ {
+ foreach (var r in Model.Reports)
+ {
+
+
+ @TypeLabel(r.TargetType): @(r.TargetLabel ?? ("#" + r.TargetId))
+ @StatusLabel(r.Status)
+
+
«@r.Reason»
+
@JalaliDate.ToLongDate(DateOnly.FromDateTime(r.CreatedAt)) · گزارشدهنده: @(r.ReporterUserId is not null ? "کاربر #" + r.ReporterUserId : "مهمان")
+
+
مشاهده مورد
+ @if (r.Status == ReportStatus.Open)
+ {
+
+
+ }
+
+
+ }
+ }
+
diff --git a/src/JobsMedical.Web/Pages/Admin/Reports.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Reports.cshtml.cs
new file mode 100644
index 0000000..7400c15
--- /dev/null
+++ b/src/JobsMedical.Web/Pages/Admin/Reports.cshtml.cs
@@ -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 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 OnPostResolveAsync(int id) => await SetStatus(id, ReportStatus.Resolved);
+ public async Task OnPostDismissAsync(int id) => await SetStatus(id, ReportStatus.Dismissed);
+
+ private async Task 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",
+ };
+}
diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml
index f3ccc61..21350b6 100644
--- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml
+++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml
@@ -12,6 +12,14 @@
+ @if (Model.SmsTest is not null) {
@Model.SmsTest
}
+
@if (Model.Saved is not null)
{
✓ @Model.Saved
diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs
index 3223caa..c422f11 100644
--- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs
+++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs
@@ -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
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();
+ }
}
diff --git a/src/JobsMedical.Web/Pages/Admin/Users.cshtml b/src/JobsMedical.Web/Pages/Admin/Users.cshtml
new file mode 100644
index 0000000..6ccec18
--- /dev/null
+++ b/src/JobsMedical.Web/Pages/Admin/Users.cshtml
@@ -0,0 +1,69 @@
+@page
+@model JobsMedical.Web.Pages.Admin.UsersModel
+@{
+ ViewData["Title"] = "مدیریت کاربران";
+ string RoleLabel(UserRole r) => r switch { UserRole.Admin => "مدیر", UserRole.FacilityAdmin => "کارفرما", _ => "کادر درمان" };
+}
+
+
+
+
+ @if (TempData["err"] is string e) {
@e
}
+
+
+
+ @foreach (var row in Model.Users)
+ {
+ var u = row.User;
+
+
+
@JalaliDate.ToPersianDigits(u.Phone)
+ @if (!string.IsNullOrEmpty(u.FullName)) {
— @u.FullName }
+
@RoleLabel(u.Role)
+ @if (row.Facilities > 0) {
@JalaliDate.ToPersianDigits(row.Facilities.ToString()) مرکز }
+ @if (u.IsBanned) {
مسدود }
+
عضویت: @JalaliDate.ToLongDate(DateOnly.FromDateTime(u.CreatedAt))@(u.IsBanned && u.BanReason != null ? " — دلیل مسدودی: " + u.BanReason : "")
+
+ @if (u.Role != UserRole.Admin)
+ {
+ @if (u.IsBanned)
+ {
+
+ }
+ else
+ {
+
+ }
+ }
+
+ }
+ @if (Model.Users.Count == 0) {
کاربری یافت نشد.
}
+
diff --git a/src/JobsMedical.Web/Pages/Admin/Users.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Users.cshtml.cs
new file mode 100644
index 0000000..f49a374
--- /dev/null
+++ b/src/JobsMedical.Web/Pages/Admin/Users.cshtml.cs
@@ -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 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 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 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 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 });
+ }
+}
diff --git a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml
index d275443..26aaad0 100644
--- a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml
+++ b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml
@@ -89,6 +89,24 @@
+ @if (Model.Reported)
+ {
+ ✓ گزارش شما ثبت شد. متشکریم.
+ }
+ else
+ {
+
+ گزارش تخلف یا اطلاعات نادرست
+
+
+ }
diff --git a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml.cs b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml.cs
index 4d2a01b..6922f77 100644
--- a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml.cs
+++ b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml.cs
@@ -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 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();
}
diff --git a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml
index 5e13e18..8ec5109 100644
--- a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml
+++ b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml
@@ -50,7 +50,7 @@
{
@if (User.IsInRole("Admin"))
{
- پنل مدیریت
+ پنل مدیریت
}
@if (User.IsInRole("FacilityAdmin"))
{
diff --git a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml
index de671c1..d775b6d 100644
--- a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml
+++ b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml
@@ -108,6 +108,24 @@
class="btn btn-outline btn-block">✕ علاقهمند نیستم
+ @if (Model.Reported)
+ {
+ ✓ گزارش شما ثبت شد. متشکریم.
+ }
+ else
+ {
+
+ گزارش تخلف یا اطلاعات نادرست
+
+
+ }
diff --git a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml.cs b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml.cs
index 9eea988..e5bf3be 100644
--- a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml.cs
+++ b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml.cs
@@ -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 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();
}
diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs
index d6d10a6..b96d4d0 100644
--- a/src/JobsMedical.Web/Program.cs
+++ b/src/JobsMedical.Web/Program.cs
@@ -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(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(['/']))); });
diff --git a/src/JobsMedical.Web/Services/NotificationService.cs b/src/JobsMedical.Web/Services/NotificationService.cs
index 2cad7ee..9411bfe 100644
--- a/src/JobsMedical.Web/Services/NotificationService.cs
+++ b/src/JobsMedical.Web/Services/NotificationService.cs
@@ -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));
+ /// Admin broadcast: notify a set of users (in-app + push) with a custom message.
+ public Task BroadcastAsync(IReadOnlyCollection 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)