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) پرچم‌خورده) · باز کن
+
@P(Model.OpenReports)
گزارش باز · رسیدگی
+
@P(Model.Banned)
کاربر مسدود · مدیریت
+
+
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)