From 17d38431bf64d819f5014aec8d2d40b3695d0cbf Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 10:27:21 +0330 Subject: [PATCH] Add SEO sitemap/robots + real SMS OTP (Kavenegar, admin-configured) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /sitemap.xml (static pages + open shifts + fresh jobs, respecting expiry) + /robots.txt (blocks /Admin,/Employer); base URL from forwarded request → https://hamkadr.ir in prod - ISmsSender + KavenegarSmsSender (verify/lookup template, sms/send fallback); SMS settings (enabled/apikey/template/sender) in /Admin/Settings; OtpService.IssueAsync sends SMS and stops revealing the code when enabled (dev still shows it); migration Co-Authored-By: Claude Opus 4.8 --- .../20260604064943_SmsSettings.Designer.cs | 894 ++++++++++++++++++ .../Migrations/20260604064943_SmsSettings.cs | 62 ++ .../Migrations/AppDbContextModelSnapshot.cs | 15 + src/JobsMedical.Web/Models/AppSetting.cs | 8 + .../Pages/Account/Login.cshtml | 6 +- .../Pages/Account/Login.cshtml.cs | 4 +- .../Pages/Admin/Settings.cshtml | 19 + .../Pages/Admin/Settings.cshtml.cs | 12 + src/JobsMedical.Web/Program.cs | 41 + src/JobsMedical.Web/Services/OtpService.cs | 34 +- .../Services/Scraping/SettingsService.cs | 4 + src/JobsMedical.Web/Services/SmsSender.cs | 64 ++ 12 files changed, 1152 insertions(+), 11 deletions(-) create mode 100644 src/JobsMedical.Web/Migrations/20260604064943_SmsSettings.Designer.cs create mode 100644 src/JobsMedical.Web/Migrations/20260604064943_SmsSettings.cs create mode 100644 src/JobsMedical.Web/Services/SmsSender.cs diff --git a/src/JobsMedical.Web/Migrations/20260604064943_SmsSettings.Designer.cs b/src/JobsMedical.Web/Migrations/20260604064943_SmsSettings.Designer.cs new file mode 100644 index 0000000..d39db90 --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260604064943_SmsSettings.Designer.cs @@ -0,0 +1,894 @@ +// +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("20260604064943_SmsSettings")] + partial class SmsSettings + { + /// + 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("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.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.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.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("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FullName") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + 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.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.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/20260604064943_SmsSettings.cs b/src/JobsMedical.Web/Migrations/20260604064943_SmsSettings.cs new file mode 100644 index 0000000..c59b915 --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260604064943_SmsSettings.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobsMedical.Web.Migrations +{ + /// + public partial class SmsSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SmsApiKey", + table: "AppSettings", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "SmsEnabled", + table: "AppSettings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SmsSender", + table: "AppSettings", + type: "character varying(30)", + maxLength: 30, + nullable: true); + + migrationBuilder.AddColumn( + name: "SmsTemplate", + table: "AppSettings", + type: "character varying(100)", + maxLength: 100, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SmsApiKey", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SmsEnabled", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SmsSender", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SmsTemplate", + table: "AppSettings"); + } + } +} diff --git a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs index 0bf897f..b45def3 100644 --- a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs +++ b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs @@ -89,6 +89,21 @@ namespace JobsMedical.Web.Migrations b.Property("Mode") .HasColumnType("integer"); + 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)"); diff --git a/src/JobsMedical.Web/Models/AppSetting.cs b/src/JobsMedical.Web/Models/AppSetting.cs index ea001b9..2c0d06d 100644 --- a/src/JobsMedical.Web/Models/AppSetting.cs +++ b/src/JobsMedical.Web/Models/AppSetting.cs @@ -54,6 +54,14 @@ public class AppSetting /// Max ads to fetch per ingestion run (be polite; dedupe skips already-seen). public int MedjobsMaxAds { get; set; } = 40; + // --- SMS OTP (Kavenegar). When off, the code is shown on screen (dev only). --- + public bool SmsEnabled { get; set; } = false; + [MaxLength(200)] public string? SmsApiKey { get; set; } + /// Kavenegar verify/lookup template name (preferred OTP method in Iran). + [MaxLength(100)] public string? SmsTemplate { get; set; } + /// Sender line for plain SMS fallback when no template is set. + [MaxLength(30)] public string? SmsSender { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; /// Split a textarea (newline/comma separated) into trimmed non-empty items. diff --git a/src/JobsMedical.Web/Pages/Account/Login.cshtml b/src/JobsMedical.Web/Pages/Account/Login.cshtml index e57410f..b52af45 100644 --- a/src/JobsMedical.Web/Pages/Account/Login.cshtml +++ b/src/JobsMedical.Web/Pages/Account/Login.cshtml @@ -44,9 +44,13 @@ {
کد تأیید (حالت توسعه): @Model.DevCode
- در نسخه‌ی نهایی این کد از طریق پیامک (کاوه‌نگار/SMS.ir) ارسال می‌شود. + در حالت عادی این کد با پیامک ارسال می‌شود.
} + else + { +
کد تأیید با پیامک به شماره شما ارسال شد.
+ }
diff --git a/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs b/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs index bc559ee..5f9bdef 100644 --- a/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs @@ -36,7 +36,7 @@ public class LoginModel : PageModel public void OnGet() { } - public IActionResult OnPostRequestCode() + public async Task OnPostRequestCodeAsync() { var phone = OtpService.Normalize(Phone); if (phone.Length < 10) @@ -45,7 +45,7 @@ public class LoginModel : PageModel return Page(); } Phone = phone; - DevCode = _otp.Issue(phone); // dev: surface the code; prod: SMS gateway sends it + DevCode = await _otp.IssueAsync(phone); // null when SMS is enabled (sent via gateway) CodeSent = true; return Page(); } diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml index da6612e..14c2b27 100644 --- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml +++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml @@ -115,6 +115,25 @@

آگهی‌های تکراری به‌صورت خودکار رد می‌شوند؛ هر اجرا فقط آگهی‌های جدید را می‌آورد.

+
+ +

پیامک ورود (OTP) — کاوه‌نگار

+
+ +
+
+ + +
+
+
+
+
+

روش پیشنهادی: تمپلیت verify/lookup با متغیر %token. اگر تمپلیت خالی باشد، پیامک ساده با خط ارسال فرستاده می‌شود.

+ diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs index b536747..adbc1c4 100644 --- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs @@ -32,6 +32,10 @@ public class SettingsModel : PageModel [BindProperty] public string? DivarQueries { get; set; } [BindProperty] public bool MedjobsEnabled { get; set; } [BindProperty] public int MedjobsMaxAds { get; set; } = 40; + [BindProperty] public bool SmsEnabled { get; set; } + [BindProperty] public string? SmsApiKey { get; set; } + [BindProperty] public string? SmsTemplate { get; set; } + [BindProperty] public string? SmsSender { get; set; } [TempData] public string? Saved { get; set; } public async Task OnGetAsync() @@ -56,6 +60,10 @@ public class SettingsModel : PageModel DivarQueries = s.DivarQueries; MedjobsEnabled = s.MedjobsEnabled; MedjobsMaxAds = s.MedjobsMaxAds; + SmsEnabled = s.SmsEnabled; + SmsApiKey = s.SmsApiKey; + SmsTemplate = s.SmsTemplate; + SmsSender = s.SmsSender; } public async Task OnPostAsync() @@ -81,6 +89,10 @@ public class SettingsModel : PageModel DivarQueries = DivarQueries, MedjobsEnabled = MedjobsEnabled, MedjobsMaxAds = MedjobsMaxAds, + SmsEnabled = SmsEnabled, + SmsApiKey = SmsApiKey, + SmsTemplate = SmsTemplate, + SmsSender = SmsSender, }); Saved = "تنظیمات ذخیره شد."; return RedirectToPage(); diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs index be5d068..27728ac 100644 --- a/src/JobsMedical.Web/Program.cs +++ b/src/JobsMedical.Web/Program.cs @@ -1,6 +1,7 @@ using System.Text.Encodings.Web; using System.Text.Unicode; using JobsMedical.Web.Data; +using JobsMedical.Web.Models; using JobsMedical.Web.Services; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.HttpOverrides; @@ -17,6 +18,8 @@ builder.Services.AddMemoryCache(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddHttpClient("sms"); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); @@ -116,4 +119,42 @@ app.MapRazorPages() // Lightweight liveness probe for the deploy health-wait loop (and uptime checks). app.MapGet("/healthz", () => Results.Text("ok")); +// ---- SEO: robots.txt + dynamic sitemap.xml (so Google indexes every live shift/job page) ---- +app.MapGet("/robots.txt", (HttpContext ctx) => +{ + var b = $"{ctx.Request.Scheme}://{ctx.Request.Host}"; + return Results.Text($"User-agent: *\nAllow: /\nDisallow: /Admin\nDisallow: /Employer\nSitemap: {b}/sitemap.xml\n", "text/plain"); +}); + +app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) => +{ + var b = $"{ctx.Request.Scheme}://{ctx.Request.Host}"; + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var jobCutoff = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc; + + var sb = new System.Text.StringBuilder(); + sb.Append("\n"); + sb.Append("\n"); + void Url(string loc, DateTime? mod = null, string freq = "daily") + { + sb.Append(" ").Append(System.Security.SecurityElement.Escape(loc)).Append(""); + if (mod is not null) sb.Append("").Append(mod.Value.ToString("yyyy-MM-dd")).Append(""); + sb.Append("").Append(freq).Append("\n"); + } + + foreach (var p in new[] { "", "/Shifts", "/Jobs", "/Calendar", "/Facilities" }) + Url($"{b}{p}", DateTime.UtcNow, p == "" ? "daily" : "hourly"); + + foreach (var s in await db.Shifts.Where(s => s.Status == ShiftStatus.Open && s.Date >= today) + .Select(s => new { s.Id, s.CreatedAt }).ToListAsync()) + Url($"{b}/Shifts/Details/{s.Id}", s.CreatedAt, "daily"); + + foreach (var j in await db.JobOpenings.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCutoff) + .Select(j => new { j.Id, j.CreatedAt }).ToListAsync()) + Url($"{b}/Jobs/Details/{j.Id}", j.CreatedAt, "weekly"); + + sb.Append(""); + return Results.Content(sb.ToString(), "application/xml"); +}); + app.Run(); diff --git a/src/JobsMedical.Web/Services/OtpService.cs b/src/JobsMedical.Web/Services/OtpService.cs index 6875184..f90bacb 100644 --- a/src/JobsMedical.Web/Services/OtpService.cs +++ b/src/JobsMedical.Web/Services/OtpService.cs @@ -1,26 +1,44 @@ +using JobsMedical.Web.Services.Scraping; using Microsoft.Extensions.Caching.Memory; namespace JobsMedical.Web.Services; /// -/// One-time-code issuing/verification. Codes live in memory for 5 minutes. In dev the code is -/// returned to the caller so it can be shown on screen; in production this is where an Iranian -/// SMS gateway (Kavenegar / SMS.ir) would send the code instead. +/// One-time-code issuing/verification. Codes live in memory for 5 minutes. When SMS is configured +/// (admin settings) the code is sent via the gateway and NOT returned; otherwise it's returned so +/// the dev login page can display it. /// public class OtpService { private readonly IMemoryCache _cache; - public OtpService(IMemoryCache cache) => _cache = cache; + private readonly ISmsSender _sms; + private readonly SettingsService _settings; + + public OtpService(IMemoryCache cache, ISmsSender sms, SettingsService settings) + { + _cache = cache; + _sms = sms; + _settings = settings; + } private static string Key(string phone) => $"otp:{Normalize(phone)}"; - /// Generate, store, and (in dev) return a 5-digit code for the phone. - public string Issue(string phone) + /// + /// Generate + store a 5-digit code. If SMS is enabled, send it and return null (don't reveal); + /// otherwise return the code so the dev login screen can show it. + /// + public async Task IssueAsync(string phone) { var code = Random.Shared.Next(10000, 100000).ToString(); _cache.Set(Key(phone), code, TimeSpan.FromMinutes(5)); - // TODO(prod): send `code` via Kavenegar/SMS.ir instead of returning it. - return code; + + var settings = await _settings.GetAsync(); + if (settings.SmsEnabled) + { + await _sms.SendOtpAsync(phone, code, settings); + return null; // never reveal the code in production + } + return code; // dev: surface it on screen } public bool Verify(string phone, string code) diff --git a/src/JobsMedical.Web/Services/Scraping/SettingsService.cs b/src/JobsMedical.Web/Services/Scraping/SettingsService.cs index b166d8c..0648a98 100644 --- a/src/JobsMedical.Web/Services/Scraping/SettingsService.cs +++ b/src/JobsMedical.Web/Services/Scraping/SettingsService.cs @@ -46,6 +46,10 @@ public class SettingsService s.DivarQueries = incoming.DivarQueries?.Trim(); s.MedjobsEnabled = incoming.MedjobsEnabled; s.MedjobsMaxAds = Math.Clamp(incoming.MedjobsMaxAds, 1, 500); + s.SmsEnabled = incoming.SmsEnabled; + s.SmsApiKey = incoming.SmsApiKey?.Trim(); + s.SmsTemplate = incoming.SmsTemplate?.Trim(); + s.SmsSender = incoming.SmsSender?.Trim(); s.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); } diff --git a/src/JobsMedical.Web/Services/SmsSender.cs b/src/JobsMedical.Web/Services/SmsSender.cs new file mode 100644 index 0000000..6f389a5 --- /dev/null +++ b/src/JobsMedical.Web/Services/SmsSender.cs @@ -0,0 +1,64 @@ +using JobsMedical.Web.Models; + +namespace JobsMedical.Web.Services; + +public interface ISmsSender +{ + /// Send the OTP code. Returns false if not configured or the gateway call fails. + Task SendOtpAsync(string phone, string code, AppSetting settings, CancellationToken ct = default); +} + +/// +/// Kavenegar SMS gateway (Iran). Prefers the verify/lookup API (a pre-approved OTP template, no +/// dedicated line needed); falls back to plain sms/send if only a sender line is configured. +/// Credentials live in AppSetting (admin panel), so no redeploy to set them. +/// +public class KavenegarSmsSender : ISmsSender +{ + private readonly IHttpClientFactory _http; + private readonly ILogger _log; + + public KavenegarSmsSender(IHttpClientFactory http, ILogger log) + { + _http = http; + _log = log; + } + + public async Task SendOtpAsync(string phone, string code, AppSetting s, CancellationToken ct = default) + { + if (!s.SmsEnabled || string.IsNullOrWhiteSpace(s.SmsApiKey)) return false; + try + { + var client = _http.CreateClient("sms"); + client.Timeout = TimeSpan.FromSeconds(15); + string url; + if (!string.IsNullOrWhiteSpace(s.SmsTemplate)) + { + // verify/lookup: template contains %token → the code + url = $"https://api.kavenegar.com/v1/{s.SmsApiKey}/verify/lookup.json" + + $"?receptor={Uri.EscapeDataString(phone)}&token={Uri.EscapeDataString(code)}" + + $"&template={Uri.EscapeDataString(s.SmsTemplate)}"; + } + else + { + var msg = $"کد ورود همکادر: {code}"; + url = $"https://api.kavenegar.com/v1/{s.SmsApiKey}/sms/send.json" + + $"?receptor={Uri.EscapeDataString(phone)}&message={Uri.EscapeDataString(msg)}" + + (string.IsNullOrWhiteSpace(s.SmsSender) ? "" : $"&sender={Uri.EscapeDataString(s.SmsSender)}"); + } + + using var resp = await client.GetAsync(url, ct); + if (!resp.IsSuccessStatusCode) + { + _log.LogWarning("Kavenegar OTP HTTP {Status} for {Phone}", (int)resp.StatusCode, phone); + return false; + } + return true; + } + catch (Exception ex) + { + _log.LogWarning(ex, "Kavenegar OTP send failed for {Phone}", phone); + return false; + } + } +}