diff --git a/src/JobsMedical.Web/Data/AppDbContext.cs b/src/JobsMedical.Web/Data/AppDbContext.cs index e10d3fb..bf83538 100644 --- a/src/JobsMedical.Web/Data/AppDbContext.cs +++ b/src/JobsMedical.Web/Data/AppDbContext.cs @@ -24,6 +24,7 @@ public class AppDbContext : DbContext public DbSet WebPushSubscriptions => Set(); public DbSet Notifications => Set(); public DbSet Reports => Set(); + public DbSet FacilityDocuments => Set(); protected override void OnModelCreating(ModelBuilder b) { @@ -77,6 +78,11 @@ public class AppDbContext : DbContext .HasOne(f => f.District).WithMany(d => d.Facilities) .HasForeignKey(f => f.DistrictId).OnDelete(DeleteBehavior.SetNull); + // Verification documents belong to a facility; remove them with it. + b.Entity() + .HasOne(d => d.Facility).WithMany(f => f.Documents) + .HasForeignKey(d => d.FacilityId).OnDelete(DeleteBehavior.Cascade); + // Don't delete shifts/profiles just because a Role is removed. b.Entity() .HasOne(s => s.Role).WithMany(r => r.Shifts) diff --git a/src/JobsMedical.Web/Migrations/20260604125119_FacilityVerificationDocs.Designer.cs b/src/JobsMedical.Web/Migrations/20260604125119_FacilityVerificationDocs.Designer.cs new file mode 100644 index 0000000..56313de --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260604125119_FacilityVerificationDocs.Designer.cs @@ -0,0 +1,1126 @@ +// +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("20260604125119_FacilityVerificationDocs")] + partial class FacilityVerificationDocs + { + /// + 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("DemoMode") + .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.Property("WebNotificationsEnabled") + .HasColumnType("boolean"); + + b.Property("WebsiteUrls") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WebsitesEnabled") + .HasColumnType("boolean"); + + 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("IsDemo") + .HasColumnType("boolean"); + + 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.Property("Verification") + .HasColumnType("integer"); + + b.Property("VerificationNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("VerificationRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Facilities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.FacilityDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("FacilityId") + .HasColumnType("integer"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.ToTable("FacilityDocuments"); + }); + + 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.FacilityDocument", b => + { + b.HasOne("JobsMedical.Web.Models.Facility", "Facility") + .WithMany("Documents") + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b => + { + 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("Documents"); + + 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/20260604125119_FacilityVerificationDocs.cs b/src/JobsMedical.Web/Migrations/20260604125119_FacilityVerificationDocs.cs new file mode 100644 index 0000000..5d89013 --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260604125119_FacilityVerificationDocs.cs @@ -0,0 +1,87 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace JobsMedical.Web.Migrations +{ + /// + public partial class FacilityVerificationDocs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Verification", + table: "Facilities", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "VerificationNote", + table: "Facilities", + type: "character varying(500)", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "VerificationRequestedAt", + table: "Facilities", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.CreateTable( + name: "FacilityDocuments", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FacilityId = table.Column(type: "integer", nullable: false), + FileName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ContentType = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Size = table.Column(type: "bigint", nullable: false), + Data = table.Column(type: "bytea", nullable: false), + UploadedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FacilityDocuments", x => x.Id); + table.ForeignKey( + name: "FK_FacilityDocuments_Facilities_FacilityId", + column: x => x.FacilityId, + principalTable: "Facilities", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_FacilityDocuments_FacilityId", + table: "FacilityDocuments", + column: "FacilityId"); + + // Backfill: already-verified facilities get Verification = Verified (2). + migrationBuilder.Sql("UPDATE \"Facilities\" SET \"Verification\" = 2 WHERE \"IsVerified\" = true;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FacilityDocuments"); + + migrationBuilder.DropColumn( + name: "Verification", + table: "Facilities"); + + migrationBuilder.DropColumn( + name: "VerificationNote", + table: "Facilities"); + + migrationBuilder.DropColumn( + name: "VerificationRequestedAt", + table: "Facilities"); + } + } +} diff --git a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs index 4f45df7..cdbd1c5 100644 --- a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs +++ b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs @@ -337,6 +337,16 @@ namespace JobsMedical.Web.Migrations b.Property("Type") .HasColumnType("integer"); + b.Property("Verification") + .HasColumnType("integer"); + + b.Property("VerificationNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("VerificationRequestedAt") + .HasColumnType("timestamp with time zone"); + b.HasKey("Id"); b.HasIndex("CityId"); @@ -348,6 +358,44 @@ namespace JobsMedical.Web.Migrations b.ToTable("Facilities"); }); + modelBuilder.Entity("JobsMedical.Web.Models.FacilityDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("FacilityId") + .HasColumnType("integer"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.ToTable("FacilityDocuments"); + }); + modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b => { b.Property("Id") @@ -902,6 +950,17 @@ namespace JobsMedical.Web.Migrations b.Navigation("OwnerUser"); }); + modelBuilder.Entity("JobsMedical.Web.Models.FacilityDocument", b => + { + b.HasOne("JobsMedical.Web.Models.Facility", "Facility") + .WithMany("Documents") + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Facility"); + }); + modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b => { b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening") @@ -1030,6 +1089,8 @@ namespace JobsMedical.Web.Migrations modelBuilder.Entity("JobsMedical.Web.Models.Facility", b => { + b.Navigation("Documents"); + b.Navigation("Shifts"); }); diff --git a/src/JobsMedical.Web/Models/Enums.cs b/src/JobsMedical.Web/Models/Enums.cs index db507eb..e8bd67d 100644 --- a/src/JobsMedical.Web/Models/Enums.cs +++ b/src/JobsMedical.Web/Models/Enums.cs @@ -96,3 +96,6 @@ public enum IngestionMode public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 } public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 } + +/// Facility verification lifecycle. Facility.IsVerified stays in sync (true only when Verified). +public enum VerificationStatus { Unverified = 0, Pending = 1, Verified = 2, Rejected = 3 } diff --git a/src/JobsMedical.Web/Models/Facility.cs b/src/JobsMedical.Web/Models/Facility.cs index 79917c0..6aaa125 100644 --- a/src/JobsMedical.Web/Models/Facility.cs +++ b/src/JobsMedical.Web/Models/Facility.cs @@ -33,7 +33,12 @@ public class Facility [MaxLength(50)] public string? BaleId { get; set; } // شناسه بله برای ارتباط - public bool IsVerified { get; set; } // نشان «تأیید شده» + public bool IsVerified { get; set; } // نشان «تأیید شده» (true only when Verification == Verified) + + /// Verification workflow: employer requests review (+docs) → admin approves/rejects. + public VerificationStatus Verification { get; set; } = VerificationStatus.Unverified; + [MaxLength(500)] public string? VerificationNote { get; set; } // admin reject reason / note + public DateTime? VerificationRequestedAt { get; set; } // Phase 2: facility self-serve. Null in MVP (admin manages). public int? OwnerUserId { get; set; } @@ -42,4 +47,7 @@ public class Facility public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public ICollection Shifts { get; set; } = new List(); + + /// Documents the employer uploaded to prove the facility is real (license, etc.). + public ICollection Documents { get; set; } = new List(); } diff --git a/src/JobsMedical.Web/Models/FacilityDocument.cs b/src/JobsMedical.Web/Models/FacilityDocument.cs new file mode 100644 index 0000000..3adc81c --- /dev/null +++ b/src/JobsMedical.Web/Models/FacilityDocument.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace JobsMedical.Web.Models; + +/// +/// A verification document an employer uploads for their facility (license, permit, ID…). +/// Stored as bytes in the DB so it survives deploys via the existing Postgres volume/backups +/// (no separate file volume to mount). Only the facility owner and admins can read it back. +/// +public class FacilityDocument +{ + public int Id { get; set; } + + public int FacilityId { get; set; } + public Facility Facility { get; set; } = null!; + + [MaxLength(200)] public string FileName { get; set; } = ""; + [MaxLength(120)] public string ContentType { get; set; } = "application/octet-stream"; + public long Size { get; set; } + + /// Raw file bytes (images/PDF). Capped at the upload handler (a few MB). + public byte[] Data { get; set; } = Array.Empty(); + + public DateTime UploadedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/JobsMedical.Web/Pages/Admin/Facilities.cshtml b/src/JobsMedical.Web/Pages/Admin/Facilities.cshtml index 2182f18..3ed21d9 100644 --- a/src/JobsMedical.Web/Pages/Admin/Facilities.cshtml +++ b/src/JobsMedical.Web/Pages/Admin/Facilities.cshtml @@ -15,25 +15,27 @@

تأیید مراکز درمانی

← صف آگهی‌ها - · @JalaliDate.ToPersianDigits(Model.Pending.Count.ToString()) مرکز در انتظار تأیید + · @JalaliDate.ToPersianDigits(Model.Awaiting.Count.ToString()) مرکز منتظر بررسی

-

در انتظار تأیید

- @if (Model.Pending.Count == 0) + @if (Model.Msg is not null) {
@Model.Msg
} + +

منتظر بررسی (مدارک ارسال‌شده)

+ @if (Model.Awaiting.Count == 0) { -
مرکزی در انتظار تأیید نیست.
+
مرکزی منتظر بررسی نیست.
} else { - foreach (var f in Model.Pending) + foreach (var f in Model.Awaiting) {
-
+
- @f.Name — @TypeLabel(f.Type) + @f.Name — @TypeLabel(f.Type) در حال بررسی
📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "") @if (f.OwnerUser is not null) { · مالک: @JalaliDate.ToPersianDigits(f.OwnerUser.Phone) } @@ -41,8 +43,32 @@
@if (!string.IsNullOrEmpty(f.Address)) {
@f.Address
}
+
+ +
+ مدارک (@JalaliDate.ToPersianDigits(f.Documents.Count.ToString())): + @if (f.Documents.Count == 0) + { + — مدرکی بارگذاری نشده. + } + else + { +
+ @foreach (var d in f.Documents) + { + 📎 @d.FileName + } +
+ } +
+ +
- + +
+
+ +
@@ -71,4 +97,24 @@
} } + + @if (Model.Others.Count > 0) + { +

سایر مراکز (بدون درخواست تأیید)

+ foreach (var f in Model.Others) + { +
+
+
+ @f.Name — @TypeLabel(f.Type) + @if (f.Verification == JobsMedical.Web.Models.VerificationStatus.Rejected) { رد شده } +
📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")
+
+
+ +
+
+
+ } + }
diff --git a/src/JobsMedical.Web/Pages/Admin/Facilities.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Facilities.cshtml.cs index 731b1fb..1d1fdee 100644 --- a/src/JobsMedical.Web/Pages/Admin/Facilities.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Admin/Facilities.cshtml.cs @@ -13,20 +13,45 @@ public class FacilitiesModel : PageModel private readonly AppDbContext _db; public FacilitiesModel(AppDbContext db) => _db = db; - public List Pending { get; private set; } = new(); + public List Awaiting { get; private set; } = new(); // requested review (Pending) + public List Others { get; private set; } = new(); // unverified / rejected, no pending request public List Verified { get; private set; } = new(); + [TempData] public string? Msg { get; set; } public async Task OnGetAsync() => await LoadAsync(); - public async Task OnPostVerifyAsync(int id) => await SetVerified(id, true); - public async Task OnPostUnverifyAsync(int id) => await SetVerified(id, false); - - private async Task SetVerified(int id, bool value) + public async Task OnPostVerifyAsync(int id) { var f = await _db.Facilities.FindAsync(id); if (f is null) return NotFound(); - f.IsVerified = value; + f.IsVerified = true; + f.Verification = VerificationStatus.Verified; + f.VerificationNote = null; await _db.SaveChangesAsync(); + Msg = $"«{f.Name}» تأیید شد."; + return RedirectToPage(); + } + + public async Task OnPostRejectAsync(int id, string? note) + { + var f = await _db.Facilities.FindAsync(id); + if (f is null) return NotFound(); + f.IsVerified = false; + f.Verification = VerificationStatus.Rejected; + f.VerificationNote = string.IsNullOrWhiteSpace(note) ? "مدارک کافی نبود." : note.Trim(); + await _db.SaveChangesAsync(); + Msg = $"«{f.Name}» رد شد."; + return RedirectToPage(); + } + + public async Task OnPostUnverifyAsync(int id) + { + var f = await _db.Facilities.FindAsync(id); + if (f is null) return NotFound(); + f.IsVerified = false; + f.Verification = VerificationStatus.Unverified; + await _db.SaveChangesAsync(); + Msg = $"تأیید «{f.Name}» لغو شد."; return RedirectToPage(); } @@ -34,8 +59,10 @@ public class FacilitiesModel : PageModel { var all = await _db.Facilities .Include(f => f.City).Include(f => f.District).Include(f => f.OwnerUser) - .OrderBy(f => f.Name).ToListAsync(); - Pending = all.Where(f => !f.IsVerified).ToList(); + .Include(f => f.Documents) + .OrderByDescending(f => f.VerificationRequestedAt).ThenBy(f => f.Name).ToListAsync(); + Awaiting = all.Where(f => f.Verification == VerificationStatus.Pending).ToList(); Verified = all.Where(f => f.IsVerified).ToList(); + Others = all.Where(f => !f.IsVerified && f.Verification != VerificationStatus.Pending).ToList(); } } diff --git a/src/JobsMedical.Web/Pages/Employer/Index.cshtml b/src/JobsMedical.Web/Pages/Employer/Index.cshtml index f299dbf..892f924 100644 --- a/src/JobsMedical.Web/Pages/Employer/Index.cshtml +++ b/src/JobsMedical.Web/Pages/Employer/Index.cshtml @@ -45,13 +45,20 @@
@c.Facility.Name - @if (c.Facility.IsVerified) + @switch (c.Facility.Verification) { - ✓ تأیید شده - } - else - { - در انتظار تأیید + case JobsMedical.Web.Models.VerificationStatus.Verified: + ✓ تأیید شده + break; + case JobsMedical.Web.Models.VerificationStatus.Pending: + در حال بررسی + break; + case JobsMedical.Web.Models.VerificationStatus.Rejected: + رد شده + break; + default: + تأیید نشده + break; }

@@ -61,6 +68,12 @@

موقعیت‌های استخدامی@JalaliDate.ToPersianDigits(c.OpenJobs.ToString())
اعلام تمایل‌ها@JalaliDate.ToPersianDigits(c.Applicants.ToString())
مدیریت آگهی‌ها و متقاضیان + @if (c.Facility.Verification != JobsMedical.Web.Models.VerificationStatus.Verified) + { + + @(c.Facility.Verification == JobsMedical.Web.Models.VerificationStatus.Pending ? "مشاهده/افزودن مدارک تأیید" : "درخواست تأیید و بارگذاری مدارک") + + }
} diff --git a/src/JobsMedical.Web/Pages/Employer/Verify.cshtml b/src/JobsMedical.Web/Pages/Employer/Verify.cshtml new file mode 100644 index 0000000..de0ece8 --- /dev/null +++ b/src/JobsMedical.Web/Pages/Employer/Verify.cshtml @@ -0,0 +1,85 @@ +@page "{id:int}" +@model JobsMedical.Web.Pages.Employer.VerifyModel +@{ + ViewData["Title"] = "تأیید مرکز درمانی"; + var v = Model.Facility.Verification; +} + +
+
+

تأیید مرکز: @Model.Facility.Name

+

← بازگشت به پنل

+
+
+ +
+ @if (Model.Msg is not null) {
@Model.Msg
} + +
+
+ وضعیت تأیید + @switch (v) + { + case JobsMedical.Web.Models.VerificationStatus.Verified: + ✓ تأیید شده + break; + case JobsMedical.Web.Models.VerificationStatus.Pending: + در حال بررسی + break; + case JobsMedical.Web.Models.VerificationStatus.Rejected: + رد شده + break; + default: + تأیید نشده + break; + } +
+ @if (v == JobsMedical.Web.Models.VerificationStatus.Rejected && !string.IsNullOrWhiteSpace(Model.Facility.VerificationNote)) + { +

دلیل رد: @Model.Facility.VerificationNote — می‌توانید مدارک اصلاح‌شده را دوباره بارگذاری کنید.

+ } + else if (v == JobsMedical.Web.Models.VerificationStatus.Pending) + { +

مدارک شما ارسال شد و توسط تیم پشتیبانی بررسی می‌شود.

+ } + else if (v == JobsMedical.Web.Models.VerificationStatus.Verified) + { +

این مرکز تأیید شده و نشان «✓ تأیید شده» را در آگهی‌ها نمایش می‌دهد.

+ } +
+ +
+

بارگذاری مدارک

+

مجوز فعالیت، پروانه مطب/مرکز، یا هر سندی که واقعی‌بودن مرکز را نشان دهد. تصویر (JPG/PNG/WebP) یا PDF، حداکثر ۵ مگابایت برای هر فایل.

+
+
+ +
+ +
+
+ +
+

مدارک بارگذاری‌شده (@JalaliDate.ToPersianDigits(Model.Docs.Count.ToString()))

+ @if (Model.Docs.Count == 0) + { +

هنوز مدرکی بارگذاری نشده است.

+ } + else + { +
+ @foreach (var d in Model.Docs) + { +
+ @d.FileName (@JalaliDate.ToPersianDigits((d.Size / 1024).ToString()) کیلوبایت) + +
+ +
+
+
+ } +
+ } +
+
diff --git a/src/JobsMedical.Web/Pages/Employer/Verify.cshtml.cs b/src/JobsMedical.Web/Pages/Employer/Verify.cshtml.cs new file mode 100644 index 0000000..04d3cdc --- /dev/null +++ b/src/JobsMedical.Web/Pages/Employer/Verify.cshtml.cs @@ -0,0 +1,85 @@ +using System.Security.Claims; +using JobsMedical.Web.Data; +using JobsMedical.Web.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; + +namespace JobsMedical.Web.Pages.Employer; + +[Authorize] +public class VerifyModel : PageModel +{ + private readonly AppDbContext _db; + public VerifyModel(AppDbContext db) => _db = db; + + public Facility Facility { get; private set; } = null!; + public List Docs { get; private set; } = new(); + [TempData] public string? Msg { get; set; } + + private static readonly string[] Allowed = { "image/jpeg", "image/png", "image/webp", "application/pdf" }; + private const long MaxBytes = 5 * 1024 * 1024; // 5 MB per file + + private int Uid => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + public async Task OnGetAsync(int id) + { + var f = await _db.Facilities.Include(x => x.City).Include(x => x.District) + .FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == Uid); + if (f is null) return NotFound(); + Facility = f; + Docs = await _db.FacilityDocuments.Where(d => d.FacilityId == id) + .OrderByDescending(d => d.UploadedAt).ToListAsync(); + return Page(); + } + + public async Task OnPostUploadAsync(int id, List files) + { + var f = await _db.Facilities.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == Uid); + if (f is null) return NotFound(); + + int added = 0; + foreach (var file in files ?? new()) + { + if (file.Length == 0) continue; + if (file.Length > MaxBytes) { Msg = "هر فایل باید کمتر از ۵ مگابایت باشد."; continue; } + var ct = (file.ContentType ?? "").ToLowerInvariant(); + if (!Allowed.Contains(ct)) { Msg = "فقط تصویر (JPG/PNG/WebP) یا PDF مجاز است."; continue; } + + using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + _db.FacilityDocuments.Add(new FacilityDocument + { + FacilityId = id, + FileName = Path.GetFileName(file.FileName), + ContentType = ct, + Size = file.Length, + Data = ms.ToArray(), + }); + added++; + } + + if (added > 0) + { + if (f.Verification != VerificationStatus.Verified) + { + f.Verification = VerificationStatus.Pending; // submitting docs = request review + f.VerificationRequestedAt = DateTime.UtcNow; + f.VerificationNote = null; + } + await _db.SaveChangesAsync(); + Msg = $"{added} سند بارگذاری و برای بررسی ارسال شد."; + } + return RedirectToPage(new { id }); + } + + public async Task OnPostDeleteDocAsync(int id, int docId) + { + var f = await _db.Facilities.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == Uid); + if (f is null) return NotFound(); + var d = await _db.FacilityDocuments.FirstOrDefaultAsync(x => x.Id == docId && x.FacilityId == id); + if (d is not null) { _db.FacilityDocuments.Remove(d); await _db.SaveChangesAsync(); } + return RedirectToPage(new { id }); + } +} diff --git a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml index 26aaad0..43ad587 100644 --- a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml +++ b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml @@ -106,6 +106,20 @@ + @if (j.Facility is not null) + { +
+ شکایت از این مرکز (@j.Facility.Name) +
+ + + + + + +
+
+ } } diff --git a/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml b/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml index 9914c3d..d47cab7 100644 --- a/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml @@ -22,12 +22,12 @@ { @JalaliDate.GenderLabel(Model.GenderRequirement) } - 📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "") @if (Model.Facility?.IsVerified == true) { ✓ تأیید شده } +
📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")
@if (Model.DistanceKm is double km) {
📍 @JalaliDate.ToPersianDigits(km.ToString("0.#")) کیلومتر از شما
diff --git a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml index d775b6d..e1354e2 100644 --- a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml +++ b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml @@ -125,6 +125,17 @@ +
+ شکایت از این مرکز (@f.Name) +
+ + + + + + +
+
} diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs index 9878d01..13dcaed 100644 --- a/src/JobsMedical.Web/Program.cs +++ b/src/JobsMedical.Web/Program.cs @@ -208,6 +208,17 @@ app.MapGet("/notifications/stream", async (HttpContext ctx, NotificationHub hub) finally { unsubscribe(); } }).RequireAuthorization(); +// Serve a facility verification document — only the facility owner or an admin may read it. +app.MapGet("/facility-doc/{id:int}", async (int id, HttpContext ctx, AppDbContext db) => +{ + var doc = await db.FacilityDocuments.Include(d => d.Facility).FirstOrDefaultAsync(d => d.Id == id); + if (doc is null) return Results.NotFound(); + var isAdmin = ctx.User.IsInRole("Admin"); + var uid = int.TryParse(ctx.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null; + if (!isAdmin && doc.Facility.OwnerUserId != uid) return Results.Forbid(); + return Results.File(doc.Data, doc.ContentType, doc.FileName); +}).RequireAuthorization(); + // User-submitted report against a listing (abuse/fake/wrong info). app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext vc, [FromForm] string targetType, [FromForm] int targetId, [FromForm] string reason,